Compare commits

...
23 Commits
Author SHA1 Message Date
zhangpu da90a803e4 feat(core): 实现碗尺寸识别与标定系统
- 新增碗尺寸识别主应用界面,支持图片拖拽、选择和摄像头实时检测
- 界面显示碗的尺寸类别、直径、置信度及算法方法
- 支持计算食物重量,依据秤示数和碗重差值计算
- 实现核心检测模块,采用霍夫圆变换和轮廓法两种检测方法融合
- 检测结果包含尺寸分类和置信度估计,支持绘制调试信息
- 提供交互式标定工具,支持批量加载样本图片并自动检测直径
- 标定工具计算阈值和碗重量,生成并保存配置文件config.json
- 界面友好,提供当前状态提示和结果预览功能
2026-08-26 18:19:45 +08:00
zhangpu 394faf26fc feat(data-management): 实现无GUI数据集下载与自动分割脚本
- 通过MySQL按日期查询数据并分页获取图像信息
- 实现SimpleDownloader支持多线程批量下载,带重试和跳过已存在文件功能
- 依据类别自动创建目录并规范化文件命名,防止文件名冲突
- 支持自定义下载超时时间、最大重试次数、并发数等参数
- 下载结束后调用已有split_dataset模块按70/15/15比例分割为训练/验证/测试集
- 支持设置样本最小阈值、类别样本上限和标记样本不足类别
- 生成下载失败日志和运行摘要,方便任务监控及后续排查
- 命令行参数灵活配置数据源、日期范围、输出路径等运行参数
2026-06-10 09:20:04 +08:00
zhangpu ad541321e5 修改数据库连接信息。 2026-06-10 09:19:26 +08:00
zhangpu 2ae7120bbc 修改参数 2026-06-02 18:05:40 +08:00
zhangpu 8ce457a8c8 清理之前批量下载的脚本 2026-04-22 16:47:07 +08:00
zhangpu 04256c5a40 整理git文件 2026-04-22 16:44:25 +08:00
zhangpu 11efe38eeb 提交多模态相关代码 2026-04-22 16:42:30 +08:00
zhangpu 0f85d371ab 修改图片前缀配置文件(后台迁移到正式环境),修改模型配置 2026-04-22 16:34:39 +08:00
zhangpu ea1fc21dc4 修改配置文件 2026-04-07 16:23:21 +08:00
zhangpu b78032f80c 修改配置文件 2026-04-07 16:12:46 +08:00
zhangpu b2a5060a19 训练图片管理面板,右侧统计从前十限制改为全部,并且支持点击直接显示。 2026-03-04 17:00:54 +08:00
zhangpu 1ff540045d 统计训练图片数量脚本中,增加导出excel功能,便于在餐厅看各个品类的数量 2026-03-04 16:42:19 +08:00
zhangpu dedf72f53c 对于类别中样本超过30个的采用K-Means算法截取前30个,存入向量数据库 2026-03-03 17:07:06 +08:00
zhangpu 8a50e2973b 修复统计各类别图片,逻辑错误问题。 2026-03-02 17:32:41 +08:00
zhangpu 7af923f1ce 增加各个脚本说明,整理项目目录。 2026-03-02 17:26:00 +08:00
zhangpu 7b9a370054 增加各个脚本说明,整理项目目录 2026-03-02 10:59:38 +08:00
giteaadmin 9b81a4f4e1 Merge pull request 'master' (#1) from cuijiacheng/FoodClassifier:master into master
Reviewed-on: #1
2026-02-09 16:01:23 +08:00
good 2b2ddfc46b 将数据集重新分割 2026-02-06 03:00:50 -05:00
good ae20e428dd 将相同的照片删除 2026-02-06 02:59:10 -05:00
good de106f8ecb 将后端的图片下载到数据集中 2026-02-06 02:57:09 -05:00
good f60c4d9883 计算照片总数以及数据库分类情况 2026-02-06 02:56:26 -05:00
good d3ab9a6d05 模型文件路径 2026-02-06 02:54:10 -05:00
good d7dd434fec 更新数据库路径 2026-02-06 02:48:44 -05:00
34 changed files with 25337 additions and 1544 deletions
+9
View File
@@ -8,3 +8,12 @@
/faiss_vector_db/faiss_index*/
/faiss_vector_db/WholeIngredientRecognition/faiss_index*/
/data_management/download_history.db
faiss_vector_db
*.jpg
*.png
*.pt
*.xlsx
SegFormer
*.bin
.vscode
.agents
+132
View File
@@ -0,0 +1,132 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
本项目采用**度量学习(Metric Learning)**的方式进行菜品识别和食材识别。核心思路是用 ResNet50 提取图像嵌入向量,配合 FAISS 向量库进行相似度检索,而非传统的端到端分类,从而支持无需重新训练即可快速添加新类别。
**三个并行识别任务:**
- `DishClassification` — 菜品识别
- `WholeIngredientRecognition` — 整体食材识别(未处理)
- `ProcessedIngredientRecognition` — 处理后食材识别(切块等)
## 常用命令
**训练嵌入模型(CosFace 损失,主要方案):**
```bash
python train/train_cosface_embedding.py
```
**CosFace 超参数网格搜索(scale × margin 组合):**
```bash
python train/grid_search_cosface.py
```
**训练嵌入模型(Triplet + Center Loss,备用方案,目前没使用这个了,主要是未采集的图片和现有分类的相似度太高了):**
```bash
python train/train_triplet_embedding.py
```
**基于训练好的模型构建 FAISS 索引:**
```bash
python faiss_vector_db/build_faiss_index.py
```
**启动主识别界面(嵌入向量方案):**
```bash
python classifier/embedding_food_classifier_app.py
```
**启动数据管理与下载界面:**
```bash
python data_management/app.py
```
**训练 SegFormer 食物区域分割模型:**
```bash
python SegFormer/training/train_minimal.py
```
**测试 SegFormer 推理效果:**
```bash
python SegFormer/inference/test_model.py
```
## 架构说明
### 度量学习流水线
```
输入图像 → ResNet50 主干网络(ImageNet1K_V2 预训练)
→ 512 维嵌入向量 → L2 归一化
→ FAISS IndexFlatIP(余弦相似度检索)
→ 返回 Top-K 相似食物及置信度分数
```
**训练损失函数:**
- **CosFace**`train_cosface_embedding.py`):大间距余弦损失;scale ≈ 64margin ≈ 0.35–0.40(因任务而异)。泛化能力更强,为首选方案。
- **Triplet + Center Loss**`train_triplet_embedding.py`):在线难样本挖掘,margin = 0.5Center Loss 权重因任务而异(0.520)。
### 关键模块
| 路径 | 功能说明 |
|------|---------|
| `net/resnet_embedding.py` | ResNet50 嵌入主干网络(512 维,L2 归一化) |
| `net/food_net.py` | 遗留的自定义 CNN,用于端到端分类(4 类) |
| `faiss_vector_db/faiss_manager.py` | FAISS 索引的增删查存核心类 |
| `faiss_vector_db/build_faiss_index.py` | 遍历数据集、提取嵌入、构建索引 |
| `classifier/embedding_food_classifier_app.py` | 主 GUICustomTkinter,支持拖拽上传) |
| `exp_multimodal/vlm_classifier.py` | 实验性:VLMOllama/Kimi+ FAISS 开放集识别 |
| `data_management/app.py` | 数据采集 GUI,从 MySQL 后端下载图片 |
| `settings/settings.py` | 全局配置(NUM_CLASSES、MODEL_DIR、DEVICE 等) |
### FAISS 索引存储结构
每个任务在 `faiss_vector_db/<任务名>/faiss_index/` 下独立存储:
- `food_embeddings.index` — FAISS 二进制索引文件
- `labels.json` / `food_labels.json` — 标签到食物名称的映射
- `embeddings.npy` — 原始向量的 numpy 数组
### 数据集目录结构
```
dataset/<任务名>/
├── train/<类别名>/ (图片)
├── val/<类别名>/
└── test/<类别名>/
```
### VLM 多模态集成(实验性)
`exp_multimodal/` 包含一套绕过封闭类别列表的开放集识别流水线:
- `vlm_providers/ollama_provider.py` — 本地 Ollama 服务(Qwen2.5-VL、LLaVA),地址:`http://192.168.1.250:11434`
- `vlm_providers/kimi_provider.py` — Kimi/Moonshot API`moonshot-v1-128k-vision-preview`
- `build_dish_name_index.py` — 使用 `bge-large-zh-v1.5` 为菜品名称构建文本向量索引
- VLM 配置保存在 `vlm_config.json`(提供商选择、API Key、候选菜品列表)
### SegFormer 食物分割
`SegFormer/` 包含用于提取食物区域的微调 SegFormer:
- 两阶段训练:先冻结编码器训练,再全量微调
- 损失函数:CrossEntropy + 0.5 × Dice Loss
- 数据以 COCO 格式存放于 `SegFormer/data/`
## 模型文件
训练好的模型保存在 `model/` 下的版本子目录中:
- `best_cosface_model.pth` — CosFace 训练的嵌入模型
- `best_embedding_model.pth` — Triplet/Center Loss 训练的嵌入模型
- `grid_search_<时间戳>/best_model_s<scale>_m<margin>.pth` — 网格搜索最优结果
当前使用的模型路径通过 `settings/settings.py` 中的 `MODEL_DIR` 指定。
## 依赖安装
```bash
pip install -r requirements.txt
```
核心依赖:`torch==2.7.0``torchvision==0.22.0``faiss-cpu>=1.7.0``customtkinter==5.2.2``tkinterdnd2==0.4.3``opencv-python==4.11.0.86`
数据管理界面连接远端 MySQL 数据库(`192.168.1.250:3308`)获取图片元数据,同时使用本地 SQLite 记录下载历史。
-149
View File
@@ -1,149 +0,0 @@
# CosFace超参数网格搜索使用指南
## 概述
已完成CosFace超参数网格搜索功能的实施,用于优化`scale (s)``margin (m)`两个核心超参数。
## 修改文件清单
### 1. `requirements.txt`
- ✅ 添加依赖:`pandas>=2.0.0``seaborn>=0.12.0`
### 2. `train/train_cosface_embedding.py`
-`TaskConfig`添加`test_dir`字段
-`TASKS`字典中所有任务添加测试集路径
### 3. `train/grid_search_cosface.py` (新建)
- ✅ 完整的网格搜索脚本 (~350行)
## 搜索空间配置
当前为dish任务配置的搜索空间:
```python
GRID_PARAMS = {
'dish': {
's': [56.0, 60.0, 64.0, 68.0], # 4个scale值
'm': [0.32, 0.35, 0.38, 0.40], # 4个margin值
}
}
```
**总配置数**: 4 × 4 = **16组实验**
## 使用方法
### 1. 安装依赖
```bash
pip install pandas>=2.0.0 seaborn>=0.12.0
```
### 2. 运行网格搜索
```bash
# 完整搜索(16组配置)
python train/grid_search_cosface.py --task dish
# 测试运行(限制配置数)
python train/grid_search_cosface.py --task dish --max_configs 2
# 自定义参数
python train/grid_search_cosface.py --task dish --epochs 100 --patience 10 --min_epochs 25
```
### 3. 参数说明
- `--task`: 任务名称 (`dish` / `whole_ingredient` / `processed_ingredient`)
- `--max_configs`: 限制最大配置数(用于测试,可选)
- `--epochs`: 每个配置的最大训练轮数(默认100)
- `--patience`: 早停容忍轮数(默认10
- `--min_epochs`: 最小训练轮数(默认25
## 输出文件
运行后会在`model/DishClassification/grid_search_YYYYMMDD_HHMMSS/`目录下生成:
1. **`grid_search_results.csv`** - 所有配置的详细结果表格
2. **`heatmap.png`** - 参数热力图(测试集准确率)
3. **`summary.txt`** - 搜索总结报告(含最佳配置)
4. **`model_s{s}_m{m}.pth`** - 每个配置的模型权重
5. **`grid_search.log`** - 完整训练日志
## 评估指标
- **主要指标**: 测试集准确率 (`test_acc`) - 用于选择最佳配置
- **辅助指标**: 验证集准确率、训练轮数、训练时间
## 应用最佳配置
网格搜索完成后:
1. 查看`summary.txt`找到最佳配置
2. 手动更新`train/train_cosface_embedding.py`中的`TASKS`字典:
```python
'dish': TaskConfig(
# ... 其他配置保持不变 ...
cosface_s=64.0, # 更新为最佳s值
cosface_m=0.38, # 更新为最佳m值
)
```
3. 后续训练将自动使用最佳配置
## 预计耗时
基于以下假设:
- 每个配置平均训练25-40个epoch(早停机制)
- 每个epoch约1-2分钟
- **单个配置**: ~30-60分钟
- **16组配置总耗时**: ~8-16小时
**建议**: 使用GPU运行,可在夜间或周末执行完整搜索。
## 注意事项
1. **数据集要求**: 确保`dataset/DishClassification/test/`目录存在且有数据
2. **GPU推荐**: 网格搜索计算量大,强烈建议使用GPU
3. **磁盘空间**: 每个配置约占用500MB,16组需8GB空间
4. **中断恢复**: 当前版本不支持断点续训,建议一次性完成
## 高级用法
### 修改搜索空间
编辑`train/grid_search_cosface.py`中的`GRID_PARAMS`字典:
```python
GRID_PARAMS = {
'dish': {
's': [60.0, 64.0, 68.0, 72.0], # 自定义scale范围
'm': [0.30, 0.35, 0.40, 0.45], # 自定义margin范围
}
}
```
### 调整早停策略
通过命令行参数调整:
```bash
python train/grid_search_cosface.py --task dish --patience 15 --min_epochs 30
```
## 故障排除
**问题1**: `ModuleNotFoundError: No module named 'pandas'`
- 解决: `pip install pandas seaborn`
**问题2**: 测试集目录不存在
- 解决: 确认`dataset/DishClassification/test/`路径正确且有数据
**问题3**: CUDA out of memory
- 解决: 减小`batch_size`或在CPU上运行(速度较慢)
## 示例结果解读
`summary.txt`示例:
```
最佳配置:
s (scale) = 64.0
m (margin) = 0.38
测试集准确率 = 98.50%
验证集准确率 = 100.00%
训练轮数 = 32
训练时间 = 45.3 分钟
```
这表示s=64.0, m=0.38是最优组合,在测试集上达到98.50%准确率。
+390
View File
@@ -0,0 +1,390 @@
# 碗尺寸识别模块 (bowl_size)
> 基于纯几何视觉方法的大/中/小碗自动识别方案,无需深度学习模型,轻量、快速、可解释。
---
## 目录
1. [为什么不用深度学习?](#为什么不用深度学习)
2. [核心思路:几何先验](#核心思路几何先验)
3. [检测算法详解](#检测算法详解)
- [预处理:CLAHE 对比度增强](#1-预处理clahe-对比度增强)
- [方法一:霍夫圆变换](#2-方法一霍夫圆变换)
- [方法二:轮廓法](#3-方法二轮廓法)
- [双路融合策略](#4-双路融合策略)
- [自适应降级重试](#5-自适应降级重试)
4. [标定系统](#标定系统)
5. [尺寸分类逻辑](#尺寸分类逻辑)
6. [文件结构](#文件结构)
7. [使用流程](#使用流程)
8. [调参指南](#调参指南)
9. [已知局限性与改进方向](#已知局限性与改进方向)
---
## 为什么不用深度学习?
直觉上,"识别大中小碗"应该用神经网络分类器。但本场景有一个极强的物理先验:
> **摄像头安装高度固定 → 碗的像素直径与真实直径成线性比例关系。**
这意味着只需要量出碗在图像中的像素直径,就可以直接判断是大/中/小碗,而不需要学习任何"视觉特征"。
| 维度 | 深度学习方案 | 本方案(纯几何) |
|------|-------------|----------------|
| 数据需求 | 每类 100+ 张 | **一次标定即可** |
| 部署新秤 | 重新采集 + 训练 | **重跑标定脚本(5分钟)** |
| 可解释性 | 黑盒 | **直接看像素直径** |
| 计算开销 | GPU / 较慢 | **CPU 毫秒级** |
| 适用性 | 高通用性 | 依赖固定安装高度 |
---
## 核心思路:几何先验
```
摄像头(固定高度 H
|
| H 固定
|
┌─────┴─────┐
│ 秤台 │
│ [ 碗 ] │
└───────────┘
真实直径 D(cm) ←→ 像素直径 d(px)
关系:d = k × D (k 是与安装高度相关的比例系数)
```
由于 `k` 对于固定安装的摄像头是常数,我们不需要求出 `k` 的具体值,只需要:
1. **标定阶段**:拍大/中/小碗图片,记录每种碗的 `d`(像素直径)均值
2. **推理阶段**:检测新图像中碗的 `d`,与标定阈值比较,直接输出大/中/小
---
## 检测算法详解
核心代码在 `bowl_detector.py``detect()` 方法中。整体流程:
```
原始图像 (BGR)
1. CLAHE 对比度增强(处理浅色碗+浅色背景)
2. 高斯模糊(去除噪点)
├──► 霍夫圆变换(HoughCircles)────┐
│ │
└──► 轮廓法(Canny + findContours)─┤
▼ 双路结果融合
(两路都失败?→ 宽松参数重试)
▼ 尺寸分类
大 / 中 / 小碗
```
---
### 1. 预处理:CLAHE 对比度增强
**问题**:白色/米色碗放在浅灰色背景上,边缘对比度极低,Canny 检测不到边。
**CLAHE(限制对比度自适应直方图均衡化)** 是一种局部对比度增强算法:
```python
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
```
原理:
- 将图像切成 8×8 的小块(tile)
- 对每个小块独立做直方图均衡化
- `clipLimit=3.0` 限制放大倍数,防止噪点被过度放大
- 相邻块之间做双线性插值,避免块状伪影
效果:即使背景和碗颜色接近,碗边缘的微弱灰度差也会被局部放大,让后续边缘检测能找到碗边。
---
### 2. 方法一:霍夫圆变换
霍夫圆变换(Hough Circle Transform)是 OpenCV 专门用于检测圆形的经典算法。
**参数说明(重要!):**
```python
cv2.HoughCircles(
image,
cv2.HOUGH_GRADIENT,
dp=1.2, # 分辨率倒数比(1=与原图同分辨率,>1降采样加速)
minDist=xxx, # 两个圆心之间的最小距离(防止重复检测同一个碗)
param1=60, # Canny边缘检测的高阈值(内部调用)
param2=25, # 圆心累加器阈值,越小越容易检测到(也越容易误检)
minRadius=xxx, # 最小圆半径(像素)
maxRadius=xxx, # 最大圆半径(像素)
)
```
**`param2` 是最关键的参数**
- 值越大 → 只检测"完美圆",漏检多
- 值越小 → 容易检测到"不完美圆",误检多
- 本方案默认 25,对于碗这种规则形状已经足够
**为什么选最大半径的圆?**
```python
best = max(circles, key=lambda c: c[2]) # c[2] 是半径
```
碗边是最大的圆形轮廓,碗底花纹、碗内装饰线都是更小的圆,取最大的才是碗边。
---
### 3. 方法二:轮廓法
作为霍夫圆的备用方案,用 Canny 边缘检测 + 轮廓分析来找圆。
**步骤:**
```python
# 1. Canny 边缘检测
edges = cv2.Canny(blurred, threshold1=20, threshold2=60)
# 2. 膨胀边缘(让断裂的边缘连接起来)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
edges = cv2.dilate(edges, kernel, iterations=1)
# 3. 找外部轮廓
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
```
**筛选条件:**
1. **面积过滤**:轮廓面积必须在图像面积的 3%~95% 之间(太小是噪点,太大是背景)
2. **圆度过滤**`circularity = 4π × 面积 / 周长²`,值为 1.0 表示完美圆,本方案要求 ≥ 0.5
```
circularity 直觉示例:
圆形 ≈ 1.0
正方形 ≈ 0.785
细长条 ≈ 0.1
```
**评分机制:**
```python
score = circularity × area
```
越圆、越大的轮廓得分越高 → 最终选出最可能是碗边缘的轮廓。
---
### 4. 双路融合策略
```python
def _merge_results(self, hough, contour):
if hough and contour:
# 两路都成功 → 取平均,互相校验,提高精度
d = (hough["diameter"] + contour["diameter"]) / 2
...
return {"method": "hough+contour", ...}
elif hough:
return hough # 只有霍夫成功
elif contour:
return contour # 只有轮廓成功
return {"diameter": 0, ...} # 全部失败
```
两路方法的优缺点互补:
| | 霍夫圆变换 | 轮廓法 |
|--|-----------|-------|
| 擅长 | 圆弧不完整也能检测 | 任意形状,更灵活 |
| 弱点 | 噪声敏感,参数敏感 | 需要轮廓连续完整 |
融合取平均后,单方误差对最终结果的影响减半。
---
### 5. 自适应降级重试
如果两路都失败(比如图像质量极差),自动用更宽松的参数再试一次:
```python
if hough_result is None and contour_result is None:
relaxed_cfg = dict(cfg)
relaxed_cfg["hough_param2"] //= 2 # 更容易检测到圆
relaxed_cfg["canny_threshold1"] //= 2 # 更敏感的边缘检测
relaxed_cfg["min_circularity"] = 0.4 # 允许更不圆的轮廓
# 用宽松参数重试...
```
这是一个"渐进式降级"策略,优先保证精度,实在不行再放宽约束。
---
## 标定系统
标定是本方案的核心步骤,**只需做一次**,永久有效(除非更换摄像头安装高度)。
### 标定原理
```
大碗样本均值: d_large ≈ 420 px
中碗样本均值: d_medium ≈ 320 px
小碗样本均值: d_small ≈ 220 px
小碗阈值上限 = (d_small + d_medium) / 2 = 270 px
中碗阈值上限 = (d_medium + d_large) / 2 = 370 px
推理时:
d ≤ 270 → 小碗
d ≤ 370 → 中碗
d > 370 → 大碗
```
取两档均值的**中点**作为阈值,使得各类别的误判容限最大化。
### 标定数据保存到 config.json
```json
{
"calibrated": true,
"thresholds": {
"small_max_diameter": 270.0,
"medium_max_diameter": 370.0
},
"calibration_averages": {
"small": 220.0,
"medium": 320.0,
"large": 420.0
},
"bowl_weights_grams": {
"small": 180,
"medium": 260,
"large": 350
}
}
```
---
## 尺寸分类逻辑
分类后还会给出一个**置信度**,反映检测到的直径距离阈值边界有多远:
```python
# 以小碗为例:
# d 越远离阈值(small_max),置信度越高
conf = min(1.0, 0.7 + (small_max - d) / small_max × 0.3)
```
置信度范围在 0.7~1.0 之间:
- `1.0`:直径远离边界,非常确定
- `0.7`:直径刚好落在边界附近,较模糊
---
## 文件结构
```
bowl_size/
├── bowl_detector.py # 核心检测类 BowlDetector
│ - detect(image) → BowlDetectionResult
│ - 双路检测 + CLAHE + 自适应降级
├── calibrate.py # 标定 GUICustomTkinter
│ - 加载大/中/小碗图片
│ - 自动检测直径并统计
│ - 计算阈值并写入 config.json
├── app.py # 主应用 GUI
│ - 图片模式:拖拽/选择图片
│ - 摄像头模式:实时检测
│ - 重量计算:秤示数 - 碗重 = 食物重量
├── config.json # 标定配置(标定后自动生成)
│ - 检测参数
│ - 尺寸阈值
│ - 碗的实际重量
└── README.md # 本文件
```
---
## 使用流程
### 第一次使用(标定)
```bash
python bowl_size/calibrate.py
```
1. 选择"小碗"→ 点击"加载该碗型图片"→ 导入 10~20 张小碗图片
2. 重复步骤 1,完成中碗、大碗的图片导入
3. 左侧"检测统计"栏会显示每种碗的平均检测直径
4. 填入各碗的实际重量(克)
5. 点击"✅ 生成标定配置" → 自动写入 `config.json`
> 💡 建议每种碗的图片在实际使用场景中拍摄(有食物、实际光线),这样标定结果更准确。
### 日常使用(识别)
```bash
python bowl_size/app.py
```
- **图片模式**:拖拽图片到预览区,或点击"打开图片"
- **摄像头模式**:点击"开启摄像头",实时检测并显示结果
- **重量计算**:识别成功后,在"秤示数"输入框填入重量,点击"计算食物重量"
---
## 调参指南
如果检测效果不理想,可修改 `config.json` 中的 `detection` 部分:
| 参数 | 作用 | 调大效果 | 调小效果 |
|------|------|---------|---------|
| `hough_param2` | 霍夫圆灵敏度 | 只检测完美圆(漏检多)| 容易误检 |
| `hough_param1` | 内部 Canny 高阈值 | 只检测强边缘 | 边缘更敏感 |
| `canny_threshold2` | 轮廓法 Canny 高阈值 | 只检测强边缘 | 边缘更敏感 |
| `min_circularity` | 轮廓圆度要求 | 只接受更圆的轮廓 | 接受更不规则的形状 |
| `blur_kernel_size` | 高斯模糊强度 | 去除更多噪点(可能模糊边缘)| 保留更多细节 |
**推荐调参顺序:**
1. 先降低 `hough_param2`(如从 25 → 15
2. 再降低 `canny_threshold2`(如从 60 → 40
3. 如果还不行,降低 `min_circularity`(如从 0.5 → 0.4
---
## 已知局限性与改进方向
### 当前局限
| 场景 | 问题 | 影响 |
|------|------|------|
| 碗没放正/严重倾斜 | 投影从圆形变为椭圆,直径偏小 | 可能误判为更小的碗 |
| 多个碗同时在画面中 | 取最大圆,可能选到错误的碗 | 建议每次识别只有一个碗 |
| 碗边被遮挡 > 30% | 轮廓不连续,圆度降低,可能检测失败 | 确保碗边清晰可见 |
| 摄像头高度改变 | 原标定失效 | 需要重新运行 calibrate.py |
### 可能的改进方向
1. **加入 YOLOv8 目标检测**:先 YOLO 定位碗的 bounding box,再在 ROI 内做几何检测,解决多碗场景
2. **椭圆拟合**:将 `minEnclosingCircle` 改为 `fitEllipse`,应对轻微倾斜
3. **结合秤重量信号**:大碗比小碗重,可做双重校验,拒绝明显矛盾的结果
4. **在线自适应**:记录历史检测直径,发现漂移时自动提醒重新标定
---
*本模块是 FoodClassifier 项目的子模块,服务于智慧秤的食物净重计算场景。*
+352
View File
@@ -0,0 +1,352 @@
"""
app.py
碗尺寸识别 - 主应用界面
功能:
- 拖拽或选择图片 → 自动识别大/中/小碗
- 显示检测结果、置信度、碗重
- 支持摄像头实时检测
- 未标定时提示用户先运行 calibrate.py
"""
import os
import sys
import threading
import cv2
import numpy as np
import customtkinter as ctk
from tkinter import filedialog, messagebox
from PIL import Image
from tkinterdnd2 import DND_FILES, TkinterDnD
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bowl_size.bowl_detector import BowlDetector, BowlSize
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PREVIEW_MAX = (560, 560)
class BowlSizeApp:
def __init__(self, root):
self.root = root
self.root.title("碗尺寸识别系统")
self.root.geometry("1100x700")
self.root.resizable(True, True)
self.detector = BowlDetector(CONFIG_PATH)
self.camera_thread = None
self.camera_running = False
self.cap = None
self.current_image_path = None
self._build_ui()
self._check_calibration()
# ---------------------------------------------------------------- #
# UI 构建
# ---------------------------------------------------------------- #
def _build_ui(self):
self.root.grid_rowconfigure(0, weight=1)
self.root.grid_columnconfigure(0, weight=1)
main = ctk.CTkFrame(self.root)
main.grid(row=0, column=0, padx=15, pady=15, sticky="nsew")
main.grid_rowconfigure(1, weight=1)
main.grid_columnconfigure(1, weight=1)
# ── 标题栏 ──────────────────────────────────────────────────
header = ctk.CTkFrame(main, height=55)
header.grid(row=0, column=0, columnspan=2, padx=10, pady=(10, 5), sticky="ew")
header.grid_propagate(False)
ctk.CTkLabel(header, text="🍜 碗尺寸识别系统",
font=ctk.CTkFont(size=22, weight="bold")).pack(
side="left", padx=20, pady=8)
ctk.CTkButton(header, text="⚙ 重新标定", width=110,
command=self._open_calibrate).pack(side="right", padx=10, pady=8)
self.calibration_status_lbl = ctk.CTkLabel(
header, text="", font=ctk.CTkFont(size=12))
self.calibration_status_lbl.pack(side="right", padx=10)
# ── 左侧控制 ─────────────────────────────────────────────────
left = ctk.CTkFrame(main, width=240)
left.grid(row=1, column=0, padx=(10, 5), pady=5, sticky="nsew")
left.grid_propagate(False)
self._build_left(left)
# ── 右侧主区域 ────────────────────────────────────────────────
right = ctk.CTkFrame(main)
right.grid(row=1, column=1, padx=(5, 10), pady=5, sticky="nsew")
right.grid_rowconfigure(0, weight=1)
right.grid_columnconfigure(0, weight=1)
self._build_right(right)
def _build_left(self, parent):
parent.grid_columnconfigure(0, weight=1)
row = 0
# 模式选择
ctk.CTkLabel(parent, text="检测模式",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(15, 5), sticky="w")
row += 1
ctk.CTkButton(parent, text="📂 打开图片", height=40,
command=self._open_image).grid(
row=row, column=0, padx=15, pady=4, sticky="ew")
row += 1
self.camera_btn = ctk.CTkButton(
parent, text="📷 开启摄像头", height=40,
command=self._toggle_camera)
self.camera_btn.grid(row=row, column=0, padx=15, pady=4, sticky="ew")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=12, sticky="ew")
row += 1
# 结果展示区
ctk.CTkLabel(parent, text="识别结果",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(0, 5), sticky="w")
row += 1
result_card = ctk.CTkFrame(parent, corner_radius=10)
result_card.grid(row=row, column=0, padx=15, pady=5, sticky="ew")
result_card.grid_columnconfigure(0, weight=1)
self.size_label = ctk.CTkLabel(
result_card, text="",
font=ctk.CTkFont(size=38, weight="bold"), text_color="#4C9BE8")
self.size_label.grid(row=0, column=0, pady=(15, 5))
self.diameter_label = ctk.CTkLabel(
result_card, text="直径:— px",
font=ctk.CTkFont(size=13), text_color="gray60")
self.diameter_label.grid(row=1, column=0, pady=2)
self.confidence_label = ctk.CTkLabel(
result_card, text="置信度:—",
font=ctk.CTkFont(size=13), text_color="gray60")
self.confidence_label.grid(row=2, column=0, pady=2)
self.method_label = ctk.CTkLabel(
result_card, text="方法:—",
font=ctk.CTkFont(size=11), text_color="gray50")
self.method_label.grid(row=3, column=0, pady=(2, 5))
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=12, sticky="ew")
row += 1
# 碗重 / 食物重量
ctk.CTkLabel(parent, text="重量计算",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(0, 5), sticky="w")
row += 1
weight_card = ctk.CTkFrame(parent, corner_radius=10)
weight_card.grid(row=row, column=0, padx=15, pady=5, sticky="ew")
weight_card.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(weight_card, text="秤示数(g)", width=90, anchor="w").grid(
row=0, column=0, padx=(10, 0), pady=(10, 3))
self.scale_entry = ctk.CTkEntry(weight_card, placeholder_text="输入秤的示数")
self.scale_entry.grid(row=0, column=1, padx=(0, 10), pady=(10, 3), sticky="ew")
ctk.CTkButton(weight_card, text="计算食物重量", height=35,
command=self._calc_food_weight).grid(
row=1, column=0, columnspan=2, padx=10, pady=(5, 5), sticky="ew")
self.bowl_weight_label = ctk.CTkLabel(
weight_card, text="碗重:— g",
font=ctk.CTkFont(size=12), text_color="gray60")
self.bowl_weight_label.grid(row=2, column=0, columnspan=2, pady=2)
self.food_weight_label = ctk.CTkLabel(
weight_card, text="食物重量:— g",
font=ctk.CTkFont(size=14, weight="bold"), text_color="#2ECC71")
self.food_weight_label.grid(row=3, column=0, columnspan=2, pady=(2, 10))
row += 1
# 状态栏
self.status_label = ctk.CTkLabel(
parent, text="请打开图片或开启摄像头",
font=ctk.CTkFont(size=11), text_color="gray60", wraplength=200)
self.status_label.grid(row=row, column=0, padx=15, pady=10, sticky="sw")
def _build_right(self, parent):
self.preview_label = ctk.CTkLabel(
parent, text="拖拽图片到此处,或使用左侧按钮打开/摄像头",
font=ctk.CTkFont(size=14), text_color="gray50",
image=None)
self.preview_label.grid(row=0, column=0, sticky="nsew")
# 拖拽支持
try:
self.preview_label.drop_target_register(DND_FILES)
self.preview_label.dnd_bind("<<Drop>>", self._on_drop)
except Exception:
pass # tkinterdnd2 not available
# ---------------------------------------------------------------- #
# 事件处理
# ---------------------------------------------------------------- #
def _check_calibration(self):
if self.detector.is_calibrated():
self.calibration_status_lbl.configure(
text="✅ 已标定", text_color="green")
else:
self.calibration_status_lbl.configure(
text="⚠ 未标定,请先运行标定工具", text_color="orange")
def _open_image(self):
path = filedialog.askopenfilename(
title="选择图片",
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp *.webp"), ("所有文件", "*.*")]
)
if path:
self._detect_file(path)
def _on_drop(self, event):
path = event.data.strip().strip("{}")
if os.path.isfile(path):
self._detect_file(path)
def _detect_file(self, path: str):
self.current_image_path = path
self._set_status("正在检测...")
def worker():
result = self.detector.detect(path, draw_debug=True)
self.root.after(0, lambda: self._show_result(result))
threading.Thread(target=worker, daemon=True).start()
def _show_result(self, result):
# 更新预览图
if result.debug_image is not None:
self._update_preview(result.debug_image)
# 更新结果
if result.is_valid:
self.size_label.configure(text=result.label, text_color="#4C9BE8")
self.diameter_label.configure(
text=f"直径:{result.pixel_diameter:.0f} px", text_color="gray60")
self.confidence_label.configure(
text=f"置信度:{result.confidence:.0%}", text_color="gray60")
self.method_label.configure(
text=f"方法:{result.method}", text_color="gray50")
bowl_w = self.detector.get_bowl_weight(result.size)
self.bowl_weight_label.configure(text=f"碗重:{bowl_w:.0f} g")
self._set_status(f"检测完成:{result.label}")
else:
self.size_label.configure(text="未检测到", text_color="gray50")
self.diameter_label.configure(text="直径:— px")
self.confidence_label.configure(text="置信度:—")
self.method_label.configure(text="方法:—")
self.bowl_weight_label.configure(text="碗重:— g")
self._set_status("⚠ 未能检测到碗,请检查图片或调整参数")
self._last_result = result
def _calc_food_weight(self):
if not hasattr(self, "_last_result") or not self._last_result.is_valid:
messagebox.showwarning("提示", "请先识别碗的尺寸!")
return
try:
total = float(self.scale_entry.get())
except ValueError:
messagebox.showerror("错误", "请输入有效的秤示数(数字)!")
return
bowl_w = self.detector.get_bowl_weight(self._last_result.size)
food_w = max(0.0, total - bowl_w)
self.food_weight_label.configure(
text=f"食物重量:{food_w:.1f} g", text_color="#2ECC71")
def _toggle_camera(self):
if self.camera_running:
self._stop_camera()
else:
self._start_camera()
def _start_camera(self):
self.cap = cv2.VideoCapture(0)
if not self.cap.isOpened():
messagebox.showerror("错误", "无法打开摄像头!")
return
self.camera_running = True
self.camera_btn.configure(text="⏹ 关闭摄像头", fg_color="red", hover_color="darkred")
self._set_status("摄像头已开启,实时检测中...")
def loop():
while self.camera_running:
ret, frame = self.cap.read()
if not ret:
break
result = self.detector.detect(frame, draw_debug=True)
if result.debug_image is not None:
self.root.after(0, lambda f=result.debug_image: self._update_preview(f))
self.root.after(0, lambda r=result: self._show_result_fast(r))
self.cap.release()
self.camera_thread = threading.Thread(target=loop, daemon=True)
self.camera_thread.start()
def _stop_camera(self):
self.camera_running = False
self.camera_btn.configure(text="📷 开启摄像头",
fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
hover_color=ctk.ThemeManager.theme["CTkButton"]["hover_color"])
self._set_status("摄像头已关闭")
def _show_result_fast(self, result):
"""摄像头模式下轻量更新(不更新预览图,已在 loop 里处理)"""
if result.is_valid:
self.size_label.configure(text=result.label, text_color="#4C9BE8")
self.diameter_label.configure(text=f"直径:{result.pixel_diameter:.0f} px")
self.confidence_label.configure(text=f"置信度:{result.confidence:.0%}")
bowl_w = self.detector.get_bowl_weight(result.size)
self.bowl_weight_label.configure(text=f"碗重:{bowl_w:.0f} g")
else:
self.size_label.configure(text="", text_color="gray50")
def _update_preview(self, bgr_img: np.ndarray):
rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
pil.thumbnail(PREVIEW_MAX, Image.LANCZOS)
ctk_img = ctk.CTkImage(pil, size=pil.size)
self.preview_label.configure(image=ctk_img, text="")
self.preview_label.image = ctk_img
def _open_calibrate(self):
import subprocess
calibrate_path = os.path.join(os.path.dirname(__file__), "calibrate.py")
subprocess.Popen([sys.executable, calibrate_path])
def _set_status(self, text: str):
self.status_label.configure(text=text)
def main():
root = TkinterDnD.Tk()
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
root.title("碗尺寸识别系统")
root.geometry("1100x700")
app = BowlSizeApp(root)
root.mainloop()
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
"""
bowl_detector.py
碗尺寸检测核心模块
检测逻辑:
1. 霍夫圆变换(HoughCircles)- 最优先,精度高
2. 轮廓法(Contour + 最大类圆轮廓)- 备用
3. 两路结果取平均(如果都成功)- 提高鲁棒性
输入:BGR 图像(numpy array 或 文件路径)
输出:BowlDetectionResult,包含 size_label / pixel_diameter / confidence / debug_image
"""
import os
import json
import math
import cv2
import numpy as np
from dataclasses import dataclass
from typing import Optional, Tuple
from enum import Enum
class BowlSize(Enum):
SMALL = "小碗"
MEDIUM = "中碗"
LARGE = "大碗"
UNKNOWN = "未知"
@dataclass
class BowlDetectionResult:
size: BowlSize = BowlSize.UNKNOWN
pixel_diameter: float = 0.0
confidence: float = 0.0
method: str = "none"
center: Tuple[int, int] = (0, 0)
debug_image: Optional[object] = None
@property
def label(self) -> str:
return self.size.value
@property
def is_valid(self) -> bool:
"""直径检测成功即视为有效(未标定时 size=UNKNOWN 但直径仍可用于标定)"""
return self.pixel_diameter > 0
@property
def is_classified(self) -> bool:
"""不仅检测到直径,且完成了尺寸分类"""
return self.size != BowlSize.UNKNOWN and self.pixel_diameter > 0
class BowlDetector:
DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or self.DEFAULT_CONFIG_PATH
self.config = self._load_config()
def detect(self, image_input, draw_debug: bool = True) -> BowlDetectionResult:
img = self._load_image(image_input)
if img is None:
return BowlDetectionResult()
h, w = img.shape[:2]
cfg = self.config.get("detection", {})
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# CLAHE 对比度增强 —— 解决浅色碗+浅色背景对比度低的问题
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
blur_k = cfg.get("blur_kernel_size", 11)
blur_k = blur_k if blur_k % 2 == 1 else blur_k + 1
blurred = cv2.GaussianBlur(enhanced, (blur_k, blur_k), 0)
hough_result = self._detect_by_hough(blurred, w, h, cfg)
contour_result = self._detect_by_contour(blurred, w, h, cfg)
# 如果两路都失败,用更宽松参数再试一次(备用策略)
if hough_result is None and contour_result is None:
relaxed_cfg = dict(cfg)
relaxed_cfg["hough_param2"] = max(10, cfg.get("hough_param2", 25) // 2)
relaxed_cfg["canny_threshold1"] = max(5, cfg.get("canny_threshold1", 20) // 2)
relaxed_cfg["canny_threshold2"] = max(20, cfg.get("canny_threshold2", 60) // 2)
relaxed_cfg["min_circularity"] = 0.4
hough_result = self._detect_by_hough(blurred, w, h, relaxed_cfg)
contour_result = self._detect_by_contour(blurred, w, h, relaxed_cfg)
merged = self._merge_results(hough_result, contour_result)
if merged["diameter"] > 0:
size, confidence = self._classify_size(merged["diameter"])
cx, cy = merged["center"]
radius = int(merged["diameter"] / 2)
detection = BowlDetectionResult(
size=size,
pixel_diameter=merged["diameter"],
confidence=confidence,
method=merged["method"],
center=(cx, cy),
)
if draw_debug:
detection.debug_image = self._draw_debug(img.copy(), cx, cy, radius, detection)
else:
detection = BowlDetectionResult()
if draw_debug:
detection.debug_image = img.copy()
return detection
def reload_config(self):
self.config = self._load_config()
def is_calibrated(self) -> bool:
return self.config.get("calibrated", False)
def get_bowl_weight(self, size: BowlSize) -> float:
key = {BowlSize.SMALL: "small", BowlSize.MEDIUM: "medium", BowlSize.LARGE: "large"}.get(size, "")
return self.config.get("bowl_weights_grams", {}).get(key, 0.0)
def _load_config(self) -> dict:
if os.path.exists(self.config_path):
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def _load_image(self, image_input) -> Optional[object]:
if isinstance(image_input, str):
return cv2.imdecode(np.fromfile(image_input, dtype=np.uint8), cv2.IMREAD_COLOR)
elif isinstance(image_input, np.ndarray):
return image_input.copy()
return None
def _detect_by_hough(self, blurred_gray, w, h, cfg) -> Optional[dict]:
min_r = int(min(w, h) * cfg.get("hough_min_radius_ratio", 0.05))
max_r = int(min(w, h) * cfg.get("hough_max_radius_ratio", 0.50))
min_dist = int(min(w, h) * cfg.get("hough_min_dist_ratio", 0.30))
circles = cv2.HoughCircles(
blurred_gray, cv2.HOUGH_GRADIENT,
dp=cfg.get("hough_dp", 1.2), minDist=min_dist,
param1=cfg.get("hough_param1", 80), param2=cfg.get("hough_param2", 35),
minRadius=min_r, maxRadius=max_r,
)
if circles is not None:
circles = np.round(circles[0, :]).astype(int)
best = max(circles, key=lambda c: c[2])
cx, cy, r = best
return {"center": (cx, cy), "diameter": float(r * 2), "method": "hough"}
return None
def _detect_by_contour(self, blurred_gray, w, h, cfg) -> Optional[dict]:
edges = cv2.Canny(blurred_gray, cfg.get("canny_threshold1", 30), cfg.get("canny_threshold2", 100))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
edges = cv2.dilate(edges, kernel, iterations=1)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
img_area = w * h
min_area = img_area * cfg.get("min_area_ratio", 0.05)
max_area = img_area * cfg.get("max_area_ratio", 0.95)
min_circ = cfg.get("min_circularity", 0.60)
best_score, best_info = -1, None
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area or area > max_area:
continue
perimeter = cv2.arcLength(cnt, True)
if perimeter == 0:
continue
circularity = 4 * math.pi * area / (perimeter ** 2)
if circularity < min_circ:
continue
(cx, cy), radius = cv2.minEnclosingCircle(cnt)
score = circularity * area
if score > best_score:
best_score = score
best_info = {"center": (int(cx), int(cy)), "diameter": float(radius * 2), "method": "contour"}
return best_info
def _merge_results(self, hough, contour) -> dict:
if hough and contour:
d = (hough["diameter"] + contour["diameter"]) / 2
cx = (hough["center"][0] + contour["center"][0]) // 2
cy = (hough["center"][1] + contour["center"][1]) // 2
return {"center": (cx, cy), "diameter": d, "method": "hough+contour"}
elif hough:
return hough
elif contour:
return contour
return {"center": (0, 0), "diameter": 0.0, "method": "none"}
def _classify_size(self, diameter: float) -> Tuple[BowlSize, float]:
thresholds = self.config.get("thresholds", {})
small_max = thresholds.get("small_max_diameter", 0)
medium_max = thresholds.get("medium_max_diameter", 0)
if not self.is_calibrated() or small_max == 0 or medium_max == 0:
return BowlSize.UNKNOWN, 0.0
if diameter <= small_max:
conf = min(1.0, 0.7 + (small_max - diameter) / small_max * 0.3)
return BowlSize.SMALL, round(conf, 3)
elif diameter <= medium_max:
margin = medium_max - small_max
conf = min(1.0, 0.7 + min(diameter - small_max, medium_max - diameter) / margin * 0.3)
return BowlSize.MEDIUM, round(conf, 3)
else:
conf = min(1.0, 0.7 + (diameter - medium_max) / medium_max * 0.3)
return BowlSize.LARGE, round(conf, 3)
def _draw_debug(self, img, cx, cy, radius, result) -> object:
color_map = {
BowlSize.SMALL: (0, 200, 100),
BowlSize.MEDIUM: (0, 165, 255),
BowlSize.LARGE: (0, 80, 255),
BowlSize.UNKNOWN: (128, 128, 128),
}
color = color_map[result.size]
cv2.circle(img, (cx, cy), radius, color, 3)
cv2.circle(img, (cx, cy), 5, color, -1)
cv2.line(img, (cx - radius, cy), (cx + radius, cy), color, 1)
lines = [
f"{result.label}",
f"Diameter: {result.pixel_diameter:.0f}px",
f"Confidence: {result.confidence:.0%}",
f"Method: {result.method}",
]
for i, line in enumerate(lines):
y = 35 + i * 30
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 0, 0), 4, cv2.LINE_AA)
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, color, 2, cv2.LINE_AA)
return img
_default_detector: Optional[BowlDetector] = None
def detect_bowl_size(image_input, config_path: Optional[str] = None) -> BowlDetectionResult:
global _default_detector
if _default_detector is None or config_path:
_default_detector = BowlDetector(config_path)
return _default_detector.detect(image_input)
+377
View File
@@ -0,0 +1,377 @@
"""
calibrate.py
碗尺寸标定工具 - 交互式 GUI
使用流程:
1. 分别为大/中/小碗加载若干样本图片
2. 工具自动检测每张图的碗直径并显示预览
3. 确认后计算阈值并写入 config.json
4. 可选:输入各碗实际重量(克)
"""
import os
import sys
import json
import threading
import cv2
import numpy as np
import customtkinter as ctk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
# 确保能找到项目根目录下的模块
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bowl_size.bowl_detector import BowlDetector, BowlSize
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PREVIEW_SIZE = (300, 300)
class CalibrationApp:
def __init__(self, root):
self.root = root
self.root.title("碗尺寸标定工具")
self.root.geometry("1100x750")
self.root.resizable(True, True)
self.detector = BowlDetector(CONFIG_PATH)
# 各尺寸的检测直径列表
self.samples = {"small": [], "medium": [], "large": []}
self.sample_images = {"small": [], "medium": [], "large": []}
self.current_size = "small"
self._build_ui()
# ---------------------------------------------------------------- #
# UI 构建
# ---------------------------------------------------------------- #
def _build_ui(self):
self.root.grid_rowconfigure(0, weight=1)
self.root.grid_columnconfigure(0, weight=1)
main = ctk.CTkFrame(self.root)
main.grid(row=0, column=0, padx=15, pady=15, sticky="nsew")
main.grid_rowconfigure(1, weight=1)
main.grid_columnconfigure(0, weight=1)
main.grid_columnconfigure(1, weight=2)
# ── 标题 ────────────────────────────────────────────────────
title = ctk.CTkLabel(main, text="碗尺寸标定工具",
font=ctk.CTkFont(size=22, weight="bold"))
title.grid(row=0, column=0, columnspan=2, pady=(10, 15))
# ── 左侧控制面板 ─────────────────────────────────────────────
left = ctk.CTkFrame(main)
left.grid(row=1, column=0, padx=(10, 5), pady=10, sticky="nsew")
left.grid_columnconfigure(0, weight=1)
self._build_left_panel(left)
# ── 右侧预览区域 ─────────────────────────────────────────────
right = ctk.CTkFrame(main)
right.grid(row=1, column=1, padx=(5, 10), pady=10, sticky="nsew")
right.grid_rowconfigure(1, weight=1)
right.grid_columnconfigure(0, weight=1)
self._build_right_panel(right)
def _build_left_panel(self, parent):
row = 0
# 当前选择的碗尺寸
ctk.CTkLabel(parent, text="当前标定碗型:",
font=ctk.CTkFont(size=14, weight="bold")).grid(
row=row, column=0, padx=15, pady=(15, 5), sticky="w")
row += 1
self.size_var = ctk.StringVar(value="small")
size_options = [("小碗 (Small)", "small"),
("中碗 (Medium)", "medium"),
("大碗 (Large)", "large")]
for text, val in size_options:
rb = ctk.CTkRadioButton(parent, text=text, variable=self.size_var,
value=val, command=self._on_size_changed)
rb.grid(row=row, column=0, padx=25, pady=3, sticky="w")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 加载图片按钮
ctk.CTkButton(parent, text="📂 加载该碗型图片", height=40,
command=self._load_images).grid(
row=row, column=0, padx=15, pady=5, sticky="ew")
row += 1
ctk.CTkButton(parent, text="🗑 清除该碗型数据", height=35,
fg_color="gray40", hover_color="gray30",
command=self._clear_current).grid(
row=row, column=0, padx=15, pady=5, sticky="ew")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 各尺寸统计
ctk.CTkLabel(parent, text="检测统计:",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(5, 3), sticky="w")
row += 1
self.stat_labels = {}
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
lbl = ctk.CTkLabel(parent, text=f"{name}: 0 张,均值 0px",
font=ctk.CTkFont(size=12), text_color="gray60")
lbl.grid(row=row, column=0, padx=25, pady=2, sticky="w")
self.stat_labels[sz] = lbl
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 碗重量输入
ctk.CTkLabel(parent, text="碗的重量(克):",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(5, 3), sticky="w")
row += 1
self.weight_entries = {}
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
frame = ctk.CTkFrame(parent, fg_color="transparent")
frame.grid(row=row, column=0, padx=15, pady=2, sticky="ew")
frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(frame, text=f"{name}: ", width=55).grid(row=0, column=0, sticky="w")
entry = ctk.CTkEntry(frame, placeholder_text="0", width=80)
entry.grid(row=0, column=1, sticky="ew")
ctk.CTkLabel(frame, text="").grid(row=0, column=2, sticky="w")
self.weight_entries[sz] = entry
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 生成标定按钮
self.calibrate_btn = ctk.CTkButton(
parent, text="✅ 生成标定配置", height=45,
font=ctk.CTkFont(size=14, weight="bold"),
fg_color="#2B8A3E", hover_color="#1F6B2E",
command=self._run_calibration)
self.calibrate_btn.grid(row=row, column=0, padx=15, pady=8, sticky="ew")
row += 1
self.status_label = ctk.CTkLabel(parent, text="请先加载各碗型图片",
text_color="gray60",
font=ctk.CTkFont(size=11),
wraplength=220)
self.status_label.grid(row=row, column=0, padx=15, pady=5, sticky="w")
def _build_right_panel(self, parent):
ctk.CTkLabel(parent, text="图片预览与检测结果",
font=ctk.CTkFont(size=14, weight="bold")).grid(
row=0, column=0, pady=(10, 5))
self.preview_frame = ctk.CTkScrollableFrame(parent)
self.preview_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew")
self.preview_widgets = []
# ---------------------------------------------------------------- #
# 事件处理
# ---------------------------------------------------------------- #
def _on_size_changed(self):
self.current_size = self.size_var.get()
self._refresh_preview()
def _load_images(self):
paths = filedialog.askopenfilenames(
title="选择图片(可多选)",
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp *.webp"), ("所有文件", "*.*")]
)
if not paths:
return
sz = self.size_var.get()
self._set_status(f"正在检测 {len(paths)} 张图片...")
def worker():
for path in paths:
result = self.detector.detect(path, draw_debug=True)
# 标定阶段:只要检测到直径就算成功(不需要完成分类)
if result.pixel_diameter > 0:
self.samples[sz].append(result.pixel_diameter)
self.sample_images[sz].append((path, result))
self.root.after(0, self._after_load)
threading.Thread(target=worker, daemon=True).start()
def _after_load(self):
self._update_stats()
self._refresh_preview()
self._set_status("加载完成,请继续加载其他碗型或生成标定配置。")
def _clear_current(self):
sz = self.size_var.get()
self.samples[sz].clear()
self.sample_images[sz].clear()
self._update_stats()
self._refresh_preview()
def _run_calibration(self):
for sz in ["small", "medium", "large"]:
if not self.samples[sz]:
messagebox.showwarning("数据不足",
f"{'小中大'[['small','medium','large'].index(sz)]}碗还没有有效样本!")
return
small_avg = np.mean(self.samples["small"])
medium_avg = np.mean(self.samples["medium"])
large_avg = np.mean(self.samples["large"])
# 确保顺序正确
sorted_avgs = sorted([(small_avg, "small"), (medium_avg, "medium"), (large_avg, "large")],
key=lambda x: x[0])
names = [x[1] for x in sorted_avgs]
avgs = [x[0] for x in sorted_avgs]
if names != ["small", "medium", "large"]:
messagebox.showwarning("数据异常",
f"检测到的碗尺寸顺序异常:\n小碗均值={small_avg:.0f}px\n"
f"中碗均值={medium_avg:.0f}px\n大碗均值={large_avg:.0f}px\n\n"
"请检查图片是否正确分类。")
return
# 阈值取相邻两档的中点
small_max = (avgs[0] + avgs[1]) / 2
medium_max = (avgs[1] + avgs[2]) / 2
# 读取碗重
weights = {}
for sz in ["small", "medium", "large"]:
try:
weights[sz] = float(self.weight_entries[sz].get() or "0")
except ValueError:
weights[sz] = 0.0
# 加载并更新配置
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
else:
config = {}
config["calibrated"] = True
config["thresholds"] = {
"small_max_diameter": round(small_max, 1),
"medium_max_diameter": round(medium_max, 1),
"description": "像素直径阈值:<=small_max为小碗,<=medium_max为中碗,其余为大碗",
}
config["calibration_samples"] = {
"small": [round(d, 1) for d in self.samples["small"]],
"medium": [round(d, 1) for d in self.samples["medium"]],
"large": [round(d, 1) for d in self.samples["large"]],
}
config["calibration_averages"] = {
"small": round(small_avg, 1),
"medium": round(medium_avg, 1),
"large": round(large_avg, 1),
}
config["bowl_weights_grams"] = weights
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo(
"标定成功",
f"标定完成!配置已保存到 config.json\n\n"
f"小碗均值: {small_avg:.0f}px\n"
f"中碗均值: {medium_avg:.0f}px\n"
f"大碗均值: {large_avg:.0f}px\n\n"
f"小碗阈值上限: {small_max:.0f}px\n"
f"中碗阈值上限: {medium_max:.0f}px"
)
self._set_status("✅ 标定配置已保存!")
# ---------------------------------------------------------------- #
# 辅助方法
# ---------------------------------------------------------------- #
def _update_stats(self):
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
diameters = self.samples[sz]
n = len(diameters)
if n > 0:
avg = np.mean(diameters)
text = f"{name}: {n} 张,均值 {avg:.0f}px"
self.stat_labels[sz].configure(text=text, text_color="green")
else:
self.stat_labels[sz].configure(text=f"{name}: 0 张", text_color="gray60")
def _refresh_preview(self):
for w in self.preview_widgets:
w.destroy()
self.preview_widgets.clear()
sz = self.size_var.get()
items = self.sample_images.get(sz, [])
if not items:
lbl = ctk.CTkLabel(self.preview_frame, text="暂无图片,请点击左侧[加载]按钮",
text_color="gray60")
lbl.grid(row=0, column=0, padx=20, pady=30)
self.preview_widgets.append(lbl)
return
col_count = 3
for i, (path, result) in enumerate(items):
r, c = divmod(i, col_count)
card = ctk.CTkFrame(self.preview_frame)
card.grid(row=r, column=c, padx=5, pady=5)
# 图片
if result.debug_image is not None:
display_img = result.debug_image
else:
display_img = cv2.imdecode(
np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)
if display_img is not None:
rgb = cv2.cvtColor(display_img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
pil.thumbnail(PREVIEW_SIZE)
ctk_img = ctk.CTkImage(pil, size=pil.size)
img_lbl = ctk.CTkLabel(card, image=ctk_img, text="")
img_lbl.image = ctk_img
img_lbl.pack(padx=5, pady=(5, 2))
# 标注信息:只要直径 > 0 就算检测成功
if result.pixel_diameter > 0:
info = f"直径: {result.pixel_diameter:.0f}px"
color = "green"
else:
info = "检测失败"
color = "red"
ctk.CTkLabel(card, text=info, text_color=color,
font=ctk.CTkFont(size=11)).pack(padx=5, pady=(0, 5))
self.preview_widgets.append(card)
def _set_status(self, text: str):
self.status_label.configure(text=text)
def main():
root = ctk.CTk()
app = CalibrationApp(root)
root.mainloop()
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
{
"_comment": "碗尺寸识别配置文件 - 通过 calibrate.py 标定后自动生成/更新",
"calibrated": true,
"scale_id": "default",
"thresholds": {
"small_max_diameter": 1167.0,
"medium_max_diameter": 1330.0,
"description": "像素直径阈值:<=small_max为小碗,<=medium_max为中碗,其余为大碗"
},
"calibration_samples": {
"small": [
1133.1,
1106.0,
1020.0,
1148.0,
1078.3,
1108.7,
1251.4,
1102.0,
1116.0,
1028.0,
1242.0,
1236.0,
1071.7,
1024.5,
1093.8,
1034.0,
1108.0,
1128.1,
1020.0,
1138.0,
1098.0,
1140.0,
1076.0,
1030.0,
1140.0,
1062.0
],
"medium": [
1258.5,
1314.6,
1174.0,
1172.1,
1180.0,
1331.9,
1251.5,
1255.9,
1237.8,
1182.0,
1250.0,
1220.9,
1271.2,
1250.3,
1213.4,
1213.3,
1185.8,
1223.7,
1192.5,
1194.0,
1182.0,
1255.4,
1252.0
],
"large": [
1358.7,
1496.2,
1344.0,
1494.0,
1464.0,
1497.7,
1494.0,
1480.4,
1503.7,
1490.0,
1403.1,
1348.5,
1434.1,
1334.6,
1442.0,
1350.3,
1490.0,
1354.7,
1412.1
]
},
"detection": {
"min_circularity": 0.5,
"min_area_ratio": 0.03,
"max_area_ratio": 0.95,
"blur_kernel_size": 11,
"canny_threshold1": 20,
"canny_threshold2": 60,
"hough_dp": 1.2,
"hough_min_dist_ratio": 0.3,
"hough_param1": 60,
"hough_param2": 25,
"hough_min_radius_ratio": 0.05,
"hough_max_radius_ratio": 0.5
},
"bowl_weights_grams": {
"small": 10.0,
"medium": 20.0,
"large": 30.0
},
"calibration_averages": {
"small": 1105.1,
"medium": 1228.8,
"large": 1431.2
}
}
-519
View File
@@ -1,519 +0,0 @@
"""
食物分类器 - FAISS索引构建器
将训练集图片转换为特征向量并构建FAISS索引用于快速相似度检索
功能:
1. 加载训练好的ResNet50 Embedding模型
2. 遍历训练集所有图片,提取特征向量
3. 构建FAISS索引文件
4. 创建标签映射文件
5. 实现快速相似度检索功能
作者: zhangpu
日期: 2025/9/22
"""
import os
import json
import pickle
import numpy as np
import torch
from PIL import Image
from tqdm import tqdm
from datetime import datetime
from typing import List, Tuple, Dict, Optional
import faiss
# 导入项目模块
from net.resnet_embedding import create_resnet50_embedding
from settings import settings
class FAISSIndexBuilder:
"""FAISS索引构建器"""
def __init__(self, model_path: str, embedding_dim: int = 512):
"""
初始化FAISS索引构建器
Args:
model_path: 训练好的模型路径
embedding_dim: 特征向量维度
"""
self.model_path = model_path
self.embedding_dim = embedding_dim
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 初始化模型
self.model = None
self.load_model()
# 数据存储
self.embeddings = []
self.image_paths = []
self.labels = []
self.class_names = []
self.class_to_idx = {}
self.idx_to_class = {}
print(f"使用设备: {self.device}")
print(f"特征向量维度: {self.embedding_dim}")
def load_model(self):
"""加载训练好的ResNet50 Embedding模型"""
try:
print(f"正在加载模型: {self.model_path}")
# 创建模型
self.model = create_resnet50_embedding(
embedding_dim=self.embedding_dim,
pretrained=True,
use_internal_preprocess=False
)
# 加载权重
checkpoint = torch.load(self.model_path, map_location=self.device)
# 处理不同的保存格式
if isinstance(checkpoint, dict):
if 'model_state_dict' in checkpoint:
self.model.load_state_dict(checkpoint['model_state_dict'])
elif 'state_dict' in checkpoint:
self.model.load_state_dict(checkpoint['state_dict'])
else:
self.model.load_state_dict(checkpoint)
else:
self.model.load_state_dict(checkpoint)
# 移动到设备并设置为评估模式
self.model.to(self.device)
self.model.eval()
print("模型加载成功!")
except Exception as e:
print(f"模型加载失败: {e}")
raise
def scan_training_data(self, train_dir: str) -> Tuple[List[str], List[str], List[int]]:
"""
扫描训练数据目录,获取所有图片路径和标签
Args:
train_dir: 训练数据目录路径
Returns:
Tuple[List[str], List[str], List[int]]: (图片路径列表, 类别名称列表, 标签索引列表)
"""
print(f"正在扫描训练数据目录: {train_dir}")
image_paths = []
class_names = []
labels = []
# 获取所有类别目录
class_dirs = [d for d in os.listdir(train_dir)
if os.path.isdir(os.path.join(train_dir, d))]
class_dirs.sort() # 确保顺序一致
print(f"发现类别: {class_dirs}")
# 创建类别映射
self.class_names = class_dirs
self.class_to_idx = {class_name: idx for idx, class_name in enumerate(class_dirs)}
self.idx_to_class = {idx: class_name for idx, class_name in enumerate(class_dirs)}
# 扫描每个类别目录
for class_name in class_dirs:
class_dir = os.path.join(train_dir, class_name)
class_idx = self.class_to_idx[class_name]
# 获取该类别下的所有图片
image_files = [f for f in os.listdir(class_dir)
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))]
print(f"类别 '{class_name}': {len(image_files)} 张图片")
for image_file in image_files:
image_path = os.path.join(class_dir, image_file)
image_paths.append(image_path)
class_names.append(class_name)
labels.append(class_idx)
print(f"总计扫描到 {len(image_paths)} 张图片")
return image_paths, class_names, labels
def extract_features_batch(self, image_paths: List[str], batch_size: int = 32) -> np.ndarray:
"""
批量提取图片特征向量
Args:
image_paths: 图片路径列表
batch_size: 批处理大小
Returns:
np.ndarray: 特征向量数组,形状为 (N, embedding_dim)
"""
print(f"开始提取特征向量,批大小: {batch_size}")
all_embeddings = []
with torch.no_grad():
for i in tqdm(range(0, len(image_paths), batch_size), desc="提取特征"):
batch_paths = image_paths[i:i + batch_size]
batch_images = []
valid_indices = []
# 加载并预处理批次图片
for j, img_path in enumerate(batch_paths):
try:
# 加载图片
image = Image.open(img_path).convert('RGB')
# 预处理
tensor = self.model.preprocess_image(image)
batch_images.append(tensor.squeeze(0)) # 移除batch维度
valid_indices.append(i + j)
except Exception as e:
print(f"警告: 无法处理图片 {img_path}: {e}")
continue
if not batch_images:
continue
# 堆叠成批次并移动到设备
batch_tensor = torch.stack(batch_images).to(self.device)
# 提取特征
batch_embeddings = self.model(batch_tensor, normalize=True)
# 转换为numpy并添加到结果中
all_embeddings.append(batch_embeddings.cpu().numpy())
# 合并所有批次的结果
if all_embeddings:
embeddings = np.vstack(all_embeddings)
print(f"成功提取 {embeddings.shape[0]} 个特征向量")
return embeddings
else:
raise ValueError("没有成功提取任何特征向量")
def build_faiss_index(self, embeddings: np.ndarray, index_type: str = 'flat') -> faiss.Index:
"""
构建FAISS索引
Args:
embeddings: 特征向量数组
index_type: 索引类型 ('flat', 'ivf', 'hnsw')
Returns:
faiss.Index: 构建好的FAISS索引
"""
print(f"构建FAISS索引,类型: {index_type}")
dimension = embeddings.shape[1]
n_vectors = embeddings.shape[0]
if index_type == 'flat':
# 精确搜索索引
index = faiss.IndexFlatIP(dimension) # 内积索引(适合归一化向量)
elif index_type == 'ivf':
# IVF索引(适合大规模数据)
nlist = min(100, max(1, n_vectors // 10)) # 聚类中心数量
quantizer = faiss.IndexFlatIP(dimension)
index = faiss.IndexIVFFlat(quantizer, dimension, nlist)
# 训练索引
print("训练IVF索引...")
index.train(embeddings.astype(np.float32))
elif index_type == 'hnsw':
# HNSW索引(快速近似搜索)
M = 16 # 连接数
index = faiss.IndexHNSWFlat(dimension, M)
index.hnsw.efConstruction = 200
else:
raise ValueError(f"不支持的索引类型: {index_type}")
# 添加向量到索引
print("添加向量到索引...")
index.add(embeddings.astype(np.float32))
print(f"索引构建完成,包含 {index.ntotal} 个向量")
return index
def save_index_and_metadata(self, index: faiss.Index, output_dir: str):
"""
保存FAISS索引和相关元数据
Args:
index: FAISS索引
output_dir: 输出目录
"""
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
# 保存FAISS索引
index_path = os.path.join(output_dir, 'faiss_index.bin')
faiss.write_index(index, index_path)
print(f"FAISS索引已保存到: {index_path}")
# 保存图片路径映射
paths_path = os.path.join(output_dir, 'image_paths.pkl')
with open(paths_path, 'wb') as f:
pickle.dump(self.image_paths, f)
print(f"图片路径映射已保存到: {paths_path}")
# 保存标签映射
labels_path = os.path.join(output_dir, 'labels.pkl')
with open(labels_path, 'wb') as f:
pickle.dump(self.labels, f)
print(f"标签映射已保存到: {labels_path}")
# 保存类别信息
class_info = {
'class_names': self.class_names,
'class_to_idx': self.class_to_idx,
'idx_to_class': self.idx_to_class,
'embedding_dim': self.embedding_dim,
'total_images': len(self.image_paths),
'build_time': datetime.now().isoformat()
}
class_info_path = os.path.join(output_dir, 'class_info.json')
with open(class_info_path, 'w', encoding='utf-8') as f:
json.dump(class_info, f, ensure_ascii=False, indent=2)
print(f"类别信息已保存到: {class_info_path}")
# 保存特征向量(可选,用于调试)
embeddings_path = os.path.join(output_dir, 'embeddings.npy')
embeddings_array = np.array(self.embeddings)
np.save(embeddings_path, embeddings_array)
print(f"特征向量已保存到: {embeddings_path}")
def build_complete_index(self, train_dir: str, output_dir: str,
batch_size: int = 32, index_type: str = 'flat'):
"""
完整的索引构建流程
Args:
train_dir: 训练数据目录
output_dir: 输出目录
batch_size: 批处理大小
index_type: 索引类型
"""
print("=" * 60)
print("开始构建FAISS索引")
print("=" * 60)
# 1. 扫描训练数据
image_paths, class_names, labels = self.scan_training_data(train_dir)
self.image_paths = image_paths
self.labels = labels
# 2. 提取特征向量
embeddings = self.extract_features_batch(image_paths, batch_size)
self.embeddings = embeddings
# 3. 构建FAISS索引
index = self.build_faiss_index(embeddings, index_type)
# 4. 保存索引和元数据
self.save_index_and_metadata(index, output_dir)
print("=" * 60)
print("FAISS索引构建完成!")
print(f"输出目录: {output_dir}")
print(f"总图片数: {len(image_paths)}")
print(f"类别数: {len(self.class_names)}")
print(f"特征维度: {self.embedding_dim}")
print("=" * 60)
return index
class FAISSSearcher:
"""FAISS相似度检索器"""
def __init__(self, index_dir: str, model_path: str):
"""
初始化FAISS检索器
Args:
index_dir: 索引文件目录
model_path: 模型路径
"""
self.index_dir = index_dir
self.model_path = model_path
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 加载索引和元数据
self.load_index_and_metadata()
# 加载模型
self.load_model()
def load_index_and_metadata(self):
"""加载FAISS索引和元数据"""
# 加载FAISS索引
index_path = os.path.join(self.index_dir, 'faiss_index.bin')
self.index = faiss.read_index(index_path)
print(f"FAISS索引已加载: {self.index.ntotal} 个向量")
# 加载图片路径
paths_path = os.path.join(self.index_dir, 'image_paths.pkl')
with open(paths_path, 'rb') as f:
self.image_paths = pickle.load(f)
# 加载标签
labels_path = os.path.join(self.index_dir, 'labels.pkl')
with open(labels_path, 'rb') as f:
self.labels = pickle.load(f)
# 加载类别信息
class_info_path = os.path.join(self.index_dir, 'class_info.json')
with open(class_info_path, 'r', encoding='utf-8') as f:
self.class_info = json.load(f)
self.class_names = self.class_info['class_names']
self.class_to_idx = self.class_info['class_to_idx']
self.idx_to_class = self.class_info['idx_to_class']
self.embedding_dim = self.class_info['embedding_dim']
print(f"元数据已加载: {len(self.image_paths)} 张图片, {len(self.class_names)} 个类别")
def load_model(self):
"""加载特征提取模型"""
self.model = create_resnet50_embedding(
embedding_dim=self.embedding_dim,
pretrained=True,
use_internal_preprocess=False
)
checkpoint = torch.load(self.model_path, map_location=self.device)
if isinstance(checkpoint, dict):
if 'model_state_dict' in checkpoint:
self.model.load_state_dict(checkpoint['model_state_dict'])
elif 'state_dict' in checkpoint:
self.model.load_state_dict(checkpoint['state_dict'])
else:
self.model.load_state_dict(checkpoint)
else:
self.model.load_state_dict(checkpoint)
self.model.to(self.device)
self.model.eval()
print("特征提取模型已加载")
def search_similar(self, query_image_path: str, k: int = 5) -> List[Tuple[str, str, float]]:
"""
搜索相似图片
Args:
query_image_path: 查询图片路径
k: 返回最相似的k张图片
Returns:
List[Tuple[str, str, float]]: [(图片路径, 类别名称, 相似度分数), ...]
"""
# 提取查询图片的特征
query_image = Image.open(query_image_path).convert('RGB')
query_embedding = self.model.extract_embedding(query_image, normalize=True)
query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
# 在FAISS索引中搜索
scores, indices = self.index.search(query_embedding, k)
# 整理结果
results = []
for i in range(k):
if i < len(indices[0]):
idx = indices[0][i]
score = scores[0][i]
image_path = self.image_paths[idx]
class_name = self.class_names[self.labels[idx]]
results.append((image_path, class_name, float(score)))
return results
def search_by_class(self, class_name: str, k: int = 10) -> List[Tuple[str, float]]:
"""
按类别搜索图片
Args:
class_name: 类别名称
k: 返回的图片数量
Returns:
List[Tuple[str, float]]: [(图片路径, 索引), ...]
"""
if class_name not in self.class_to_idx:
raise ValueError(f"未知类别: {class_name}")
class_idx = self.class_to_idx[class_name]
class_images = []
for i, label in enumerate(self.labels):
if label == class_idx:
class_images.append((self.image_paths[i], i))
if len(class_images) >= k:
break
return class_images
def main():
"""主函数"""
# 配置参数
MODEL_PATH = "model/embedding_20250930_102826/best_embedding_model.pth"
TRAIN_DIR = "dataset/train"
OUTPUT_DIR = "faiss_vector_db/DishClassification/faiss_index"
BATCH_SIZE = 16
INDEX_TYPE = 'flat' # 'flat', 'ivf', 'hnsw'
EMBEDDING_DIM = 512
try:
# 构建索引
builder = FAISSIndexBuilder(MODEL_PATH, EMBEDDING_DIM)
index = builder.build_complete_index(
train_dir=TRAIN_DIR,
output_dir=OUTPUT_DIR,
batch_size=BATCH_SIZE,
index_type=INDEX_TYPE
)
print("\n" + "=" * 60)
print("测试相似度检索功能")
print("=" * 60)
# 测试检索功能
searcher = FAISSSearcher(OUTPUT_DIR, MODEL_PATH)
# 随机选择一张图片进行测试
test_image_path = builder.image_paths[0]
print(f"测试图片: {test_image_path}")
# 搜索相似图片
similar_images = searcher.search_similar(test_image_path, k=5)
print("\n最相似的5张图片:")
for i, (img_path, class_name, score) in enumerate(similar_images, 1):
print(f"{i}. {os.path.basename(img_path)} (类别: {class_name}, 相似度: {score:.4f})")
# 按类别搜索
print(f"\n'{builder.class_names[0]}'类别的前5张图片:")
class_images = searcher.search_by_class(builder.class_names[0], k=5)
for i, (img_path, idx) in enumerate(class_images, 1):
print(f"{i}. {os.path.basename(img_path)} (索引: {idx})")
except Exception as e:
print(f"错误: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()
+10 -2
View File
@@ -1,3 +1,6 @@
"""
目前可视化用这个脚本,采用度量学习的方式
"""
import os
import json
import cv2
@@ -67,7 +70,8 @@ class EmbeddingFoodClassifierApp:
# model_path = os.path.join(BASE_DIR, "../model/embedding_20251011_133653/best_embedding_model.pth")
# model_path = os.path.join(BASE_DIR, "../model/ProcessedIngredientRecognition/embedding_20251029_170904/best_embedding_model.pth")
# model_path = os.path.join(BASE_DIR, "../model/WholeIngredientRecognition/cosface_20251106_134718/best_cosface_model.pth")
model_path = os.path.join(BASE_DIR, "../model/WholeIngredientRecognition/grid_search_20251204_140816/model_s64.0_m0.4.pth")
# model_path = os.path.join(BASE_DIR, "../model/WholeIngredientRecognition/grid_search_20251204_140816/model_s64.0_m0.4.pth")
model_path = os.path.join(BASE_DIR, "../model/WholeIngredientRecognition/grid_search_20260423_175941/best_model_s64.0_m0.45.pth")
# model_path = os.path.join(BASE_DIR, "../model/DishClassification/cosface_20251111_153649/best_cosface_model.pth")
# FAISS索引目录
@@ -1147,7 +1151,11 @@ class EmbeddingFoodClassifierApp:
# 提取查询图片的特征向量
query_embedding = self.model.extract_embedding(pil_image, normalize=True)
# print('特征向量:', query_embedding)
# 打印前50位向量,用于与秤端结果对比
# print("=" * 60)
# print(f"[PC端] 模型输出向量(前50位,共{len(query_embedding)}维):")
# print(", ".join(f"{v:.6f}" for v in query_embedding[:50]))
# print("=" * 60)
query_embedding = query_embedding.reshape(1, -1).astype(np.float32)
# 在FAISS索引中搜索最相似的k张图片
@@ -1,3 +1,9 @@
"""
这个脚本是当时过渡期用的一个脚本
背景是,当时安卓端没做向量数据库
所以,用矩阵乘法的方式,求相似度
现在安卓端已集成向量数据库,暂时不用这个脚本
"""
import os
import json
import cv2
+3
View File
@@ -1,3 +1,6 @@
"""
这个是最早用的一个脚本,采用端到端的训练模式
"""
import os
import json
import cv2
+4
View File
@@ -1,3 +1,7 @@
"""
这个脚本主要用于样本增加,主要是前期搜集图片来源于网络
收集难度大,为了增加样本多样本,提高模型泛化能力
"""
import os
import random
from PIL import Image
+1
View File
@@ -96,6 +96,7 @@ class DataManagementApp:
on_download_history_callback=self._on_download_history,
on_export_callback=self._on_export,
on_settings_callback=self._on_settings,
on_item_click_callback=lambda name: self.filter_panel.set_goods_name_and_query(name),
width=RIGHT_PANEL_WIDTH
)
self.statistics_panel.pack(side="right", fill="y", padx=(5, 0))
+3 -3
View File
@@ -5,11 +5,11 @@ import os
# ======================== MySQL数据库配置 ========================
MYSQL_CONFIG = {
'host': '192.168.1.250',
'host': '192.168.10.118',
'port': 3308,
'user': 'root',
'password': 'Aa135790123',
'database': 'yx_terminal',
'database': 'yx_terminal_uat',
'charset': 'utf8mb4'
}
@@ -21,7 +21,7 @@ TABLE_MAPPING = {
}
# ======================== 图片URL前缀 ========================
IMAGE_URL_PREFIX = 'http://dev.yixiong-tech.com:8081'
IMAGE_URL_PREFIX = 'https://file.dm.yixiong-tech.com:8443'
# ======================== SQLite下载历史数据库 ========================
# 存储在项目的data_management目录下
+227
View File
@@ -0,0 +1,227 @@
"""
统计每个类别的图片数量(包含train/val/test),并可选择删除样本不足的类别
"""
import os
import shutil
from pathlib import Path
from collections import defaultdict
def count_images_by_class(dataset_root: str, output_file: str = None, delete_threshold: int = None, auto_confirm: bool = False):
"""
统计每个类别的图片数量(包含train/val/test),并可选择删除样本不足的类别
Args:
dataset_root: 数据集根目录(包含train/val/test目录)
output_file: 输出文件路径(可选,如果提供则保存到文件)
delete_threshold: 删除阈值(可选,如果提供则删除总数少于该值的类别)
auto_confirm: 是否自动确认删除(默认False,需要用户确认)
"""
dataset_root = Path(dataset_root)
train_dir = dataset_root / 'train'
val_dir = dataset_root / 'val'
test_dir = dataset_root / 'test'
if not train_dir.exists():
print(f"错误: 训练目录不存在: {train_dir}")
return
print("="*80)
print("每个类别的图片数量统计(包含train/val/test")
print("="*80)
class_counts = {}
class_counts_detail = {} # 详细统计:train/val/test分别的数量
total_images = 0
total_train = 0
total_val = 0
total_test = 0
# 获取所有类别文件夹(从train目录)
class_folders = sorted([f for f in train_dir.iterdir() if f.is_dir()])
# 也检查val和test目录中是否有train中没有的类别
all_class_names = set()
for folder in class_folders:
all_class_names.add(folder.name)
if val_dir.exists():
for folder in val_dir.iterdir():
if folder.is_dir():
all_class_names.add(folder.name)
if test_dir.exists():
for folder in test_dir.iterdir():
if folder.is_dir():
all_class_names.add(folder.name)
# 统计每个类别的图片数量
for class_name in sorted(all_class_names):
train_count = 0
val_count = 0
test_count = 0
# 统计train目录
train_class_dir = train_dir / class_name
if train_class_dir.exists():
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
train_count += len(list(train_class_dir.glob(ext)))
# 统计val目录
val_class_dir = val_dir / class_name
if val_class_dir.exists():
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
val_count += len(list(val_class_dir.glob(ext)))
# 统计test目录
test_class_dir = test_dir / class_name
if test_class_dir.exists():
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
test_count += len(list(test_class_dir.glob(ext)))
total = train_count + val_count + test_count
class_counts[class_name] = total
class_counts_detail[class_name] = {
'train': train_count,
'val': val_count,
'test': test_count,
'total': total
}
total_images += total
total_train += train_count
total_val += val_count
total_test += test_count
# 按数量排序
sorted_classes = sorted(class_counts.items(), key=lambda x: x[1], reverse=True)
# 修复文件开头的注释
if output_file:
# 确保sorted_classes已定义
pass
# 打印结果
print(f"\n总类别数: {len(class_counts)}")
print(f"总图片数: {total_images} (训练集: {total_train}, 验证集: {total_val}, 测试集: {total_test})")
print(f"平均每类: {total_images // len(class_counts) if len(class_counts) > 0 else 0}\n")
print("-"*100)
print(f"{'类别名称':<50} {'总计':<8} {'训练':<8} {'验证':<8} {'测试':<8} {'状态':<10}")
print("-"*100)
# 统计样本不足的类别(假设阈值是10)
min_threshold = 10
insufficient_count = 0
for class_name, count in sorted_classes:
detail = class_counts_detail[class_name]
status = "⚠样本不足" if count < min_threshold else ""
if count < min_threshold:
insufficient_count += 1
print(f"{class_name:<50} {detail['total']:<8} {detail['train']:<8} {detail['val']:<8} {detail['test']:<8} {status:<10}")
print("-"*100)
print(f"\n样本不足的类别数(<{min_threshold}张): {insufficient_count}")
print(f"样本充足的类别数(>={min_threshold}张): {len(class_counts) - insufficient_count}")
# 删除样本不足的类别(如果指定了删除阈值)
if delete_threshold is not None:
classes_to_delete = [class_name for class_name, count in class_counts.items() if count < delete_threshold]
if classes_to_delete:
print("\n" + "="*100)
print(f"发现 {len(classes_to_delete)} 个类别的总图片数少于 {delete_threshold} 张:")
print("="*100)
for class_name in classes_to_delete:
detail = class_counts_detail[class_name]
print(f" - {class_name}: {detail['total']} 张 (训练:{detail['train']}, 验证:{detail['val']}, 测试:{detail['test']})")
# 确认删除
if not auto_confirm:
confirm = input(f"\n是否删除这 {len(classes_to_delete)} 个类别? (yes/no): ").strip().lower()
if confirm not in ['yes', 'y']:
print("取消删除操作")
return
# 执行删除
deleted_count = 0
for class_name in classes_to_delete:
try:
# 删除train目录中的类别文件夹
train_class_dir = train_dir / class_name
if train_class_dir.exists():
shutil.rmtree(train_class_dir)
print(f"✓ 已删除: {train_class_dir}")
# 删除val目录中的类别文件夹
val_class_dir = val_dir / class_name
if val_class_dir.exists():
shutil.rmtree(val_class_dir)
print(f"✓ 已删除: {val_class_dir}")
# 删除test目录中的类别文件夹
test_class_dir = test_dir / class_name
if test_class_dir.exists():
shutil.rmtree(test_class_dir)
print(f"✓ 已删除: {test_class_dir}")
deleted_count += 1
except Exception as e:
print(f"✗ 删除失败 {class_name}: {e}")
print(f"\n删除完成: 成功删除 {deleted_count}/{len(classes_to_delete)} 个类别")
else:
print(f"\n没有找到总数少于 {delete_threshold} 张的类别")
# 保存到文件(如果指定)
if output_file:
output_path = Path(output_file)
with open(output_path, 'w', encoding='utf-8') as f:
f.write("="*100 + "\n")
f.write("每个类别的图片数量统计(包含train/val/test\n")
f.write("="*100 + "\n\n")
f.write(f"总类别数: {len(class_counts)}\n")
f.write(f"总图片数: {total_images} (训练集: {total_train}, 验证集: {total_val}, 测试集: {total_test})\n")
f.write(f"平均每类: {total_images // len(class_counts) if len(class_counts) > 0 else 0}\n\n")
f.write("-"*100 + "\n")
f.write(f"{'类别名称':<50} {'总计':<8} {'训练':<8} {'验证':<8} {'测试':<8} {'状态':<10}\n")
f.write("-"*100 + "\n")
for class_name, count in sorted_classes:
detail = class_counts_detail[class_name]
status = "⚠样本不足" if count < min_threshold else ""
f.write(f"{class_name:<50} {detail['total']:<8} {detail['train']:<8} {detail['val']:<8} {detail['test']:<8} {status:<10}\n")
f.write("-"*100 + "\n")
f.write(f"\n样本不足的类别数(<{min_threshold}张): {insufficient_count}\n")
f.write(f"样本充足的类别数(>={min_threshold}张): {len(class_counts) - insufficient_count}\n")
print(f"\n✓ 统计结果已保存到: {output_path}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='统计每个类别的图片数量,并可选择删除样本不足的类别')
parser.add_argument('--dataset', type=str,
default='dataset/WholeIngredientRecognition',
help='数据集根目录(默认: dataset/WholeIngredientRecognition')
parser.add_argument('--output', type=str, default=None,
help='输出文件路径(可选,保存统计结果到文件)')
parser.add_argument('--delete', type=int, default=None,
help='删除阈值(可选,删除总数少于该值的类别,例如: --delete 20')
parser.add_argument('--yes', action='store_true',
help='自动确认删除,不需要手动输入yes(谨慎使用)')
args = parser.parse_args()
# 获取项目根目录
script_dir = Path(__file__).parent
project_root = script_dir.parent
# 处理相对路径
if not os.path.isabs(args.dataset):
dataset_path = project_root / args.dataset
else:
dataset_path = Path(args.dataset)
count_images_by_class(str(dataset_path), args.output, args.delete, args.yes)
+344
View File
@@ -0,0 +1,344 @@
"""
统计每个类别的图片数量(包含train/val/test
"""
import os
from pathlib import Path
from collections import defaultdict
from datetime import datetime
def export_to_excel(sorted_classes, class_counts_detail, class_counts,
total_images, total_train, total_val, total_test,
min_threshold, dataset_root: Path, output_dir: Path = None):
"""
将统计结果导出为 Excel 文件(带格式,方便粘贴到飞书)
Args:
sorted_classes: 按数量排序的类别列表
class_counts_detail: 每类的详细统计
class_counts: 每类的总数
total_images/train/val/test: 各分区汇总
min_threshold: 样本不足阈值
dataset_root: 数据集根目录(用于生成文件名)
output_dir: Excel 输出目录(默认和脚本同目录)
"""
try:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
except ImportError:
print("❌ 未找到 openpyxl,请运行: pip install openpyxl")
return
wb = Workbook()
ws = wb.active
ws.title = "图片数量统计"
# ── 颜色 & 样式定义 ──────────────────────────────────────────
COLOR_HEADER_BG = "2563EB" # 深蓝色表头背景
COLOR_HEADER_FONT = "FFFFFF" # 白色表头字
COLOR_WARN_BG = "FEF08A" # 淡黄色:样本不足行
COLOR_SUMMARY_BG = "F1F5F9" # 浅灰:汇总行
COLOR_TOTAL_BG = "DBEAFE" # 浅蓝:总计行
def make_font(bold=False, color="000000", size=11):
return Font(bold=bold, color=color, size=size, name="微软雅黑")
def make_fill(hex_color):
return PatternFill("solid", fgColor=hex_color)
def make_border():
thin = Side(style="thin", color="CBD5E1")
return Border(left=thin, right=thin, top=thin, bottom=thin)
center = Alignment(horizontal="center", vertical="center")
left = Alignment(horizontal="left", vertical="center")
# ── 第1行:大标题 ────────────────────────────────────────────
ws.merge_cells("A1:F1")
title_cell = ws["A1"]
title_cell.value = f"📊 图片数量统计 — {dataset_root.name}"
title_cell.font = make_font(bold=True, color="1E3A5F", size=14)
title_cell.alignment = center
title_cell.fill = make_fill("EFF6FF")
ws.row_dimensions[1].height = 32
# ── 第2行:生成时间 ──────────────────────────────────────────
ws.merge_cells("A2:F2")
time_cell = ws["A2"]
time_cell.value = f"生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} 数据集: {dataset_root}"
time_cell.font = make_font(color="64748B", size=9)
time_cell.alignment = center
time_cell.fill = make_fill("EFF6FF")
ws.row_dimensions[2].height = 18
# ── 第3行:空行分隔 ──────────────────────────────────────────
ws.row_dimensions[3].height = 8
# ── 第4行:汇总信息 ──────────────────────────────────────────
summary_labels = [
("总类别数", len(class_counts)),
("总图片数", total_images),
("训练集", total_train),
("验证集", total_val),
("测试集", total_test),
("平均每类", total_images // len(class_counts) if len(class_counts) > 0 else 0),
]
insufficient_count = sum(1 for _, cnt in sorted_classes if cnt < min_threshold)
# 汇总用两行:标签行 + 数值行
for col_idx, (label, _) in enumerate(summary_labels, start=1):
cell = ws.cell(row=4, column=col_idx, value=label)
cell.font = make_font(bold=True, color="475569", size=10)
cell.alignment = center
cell.fill = make_fill(COLOR_SUMMARY_BG)
cell.border = make_border()
ws.row_dimensions[4].height = 20
for col_idx, (_, value) in enumerate(summary_labels, start=1):
cell = ws.cell(row=5, column=col_idx, value=value)
cell.font = make_font(bold=True, size=12)
cell.alignment = center
cell.fill = make_fill(COLOR_TOTAL_BG)
cell.border = make_border()
ws.row_dimensions[5].height = 22
# ── 第6行:空行 ───────────────────────────────────────────────
ws.row_dimensions[6].height = 8
# ── 第7行:列表头 ────────────────────────────────────────────
headers = ["类别名称", "总计", "训练集", "验证集", "测试集", "状态"]
for col_idx, header in enumerate(headers, start=1):
cell = ws.cell(row=7, column=col_idx, value=header)
cell.font = make_font(bold=True, color=COLOR_HEADER_FONT, size=11)
cell.fill = make_fill(COLOR_HEADER_BG)
cell.alignment = center
cell.border = make_border()
ws.row_dimensions[7].height = 24
# ── 数据行(从第8行开始)────────────────────────────────────
for row_offset, (class_name, count) in enumerate(sorted_classes):
row = 8 + row_offset
detail = class_counts_detail[class_name]
is_warn = count < min_threshold
status = "⚠ 样本不足" if is_warn else ""
row_data = [class_name, detail['total'], detail['train'],
detail['val'], detail['test'], status]
for col_idx, value in enumerate(row_data, start=1):
cell = ws.cell(row=row, column=col_idx, value=value)
cell.border = make_border()
cell.alignment = left if col_idx == 1 else center
cell.font = make_font(size=10)
if is_warn:
cell.fill = make_fill(COLOR_WARN_BG)
elif row_offset % 2 == 1:
cell.fill = make_fill("F8FAFC") # 斑马条纹
ws.row_dimensions[row].height = 18
# ── 最后一行:样本汇总说明 ───────────────────────────────────
last_row = 8 + len(sorted_classes)
ws.merge_cells(f"A{last_row}:F{last_row}")
note_cell = ws[f"A{last_row}"]
note_cell.value = (f"样本不足(< {min_threshold} 张): {insufficient_count} "
f"样本充足(≥ {min_threshold} 张): {len(class_counts) - insufficient_count}")
note_cell.font = make_font(bold=True, color="475569", size=10)
note_cell.alignment = center
note_cell.fill = make_fill(COLOR_SUMMARY_BG)
note_cell.border = make_border()
ws.row_dimensions[last_row].height = 22
# ── 备注行:采样目标说明 ─────────────────────────────────────
remark_row = last_row + 1
ws.merge_cells(f"A{remark_row}:F{remark_row}")
remark_cell = ws[f"A{remark_row}"]
remark_cell.value = "现阶段目标:每个类别至少 100 张样本。后期可根据实际情况调整阈值。高质量样本越多,模型泛化能力越强。"
remark_cell.font = make_font(color="64748B", size=9)
remark_cell.alignment = center
remark_cell.fill = make_fill("EFF6FF")
remark_cell.border = make_border()
ws.row_dimensions[remark_row].height = 18
# ── 列宽自适应 ───────────────────────────────────────────────
col_widths = [40, 8, 8, 8, 8, 12]
for col_idx, width in enumerate(col_widths, start=1):
ws.column_dimensions[get_column_letter(col_idx)].width = width
# ── 冻结表头(冻结到第8行,滚动时列头始终可见)──────────────
ws.freeze_panes = "A8"
# ── 生成文件名 & 保存 ────────────────────────────────────────
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"image_count_{dataset_root.name}_{timestamp}.xlsx"
save_dir = output_dir if output_dir else Path(__file__).parent
save_path = save_dir / filename
wb.save(save_path)
print(f"\n✅ Excel 已导出: {save_path}")
print(f" (直接打开后全选复制,粘贴到飞书云文档即可保留格式)")
return save_path
def count_images_by_class(dataset_root: str, output_file: str = None,
export_excel: bool = False):
"""
统计每个类别的图片数量(包含train/val/test
Args:
dataset_root: 数据集根目录(包含train/val/test目录)
output_file: 输出文件路径(可选,如果提供则保存到 txt 文件)
export_excel: 是否同时导出 Excel 文件(默认 False
"""
dataset_root = Path(dataset_root)
train_dir = dataset_root / 'train'
val_dir = dataset_root / 'val'
test_dir = dataset_root / 'test'
if not train_dir.exists():
print(f"错误: 训练目录不存在: {train_dir}")
return
print("="*80)
print("每个类别的图片数量统计(包含train/val/test")
print("="*80)
class_counts = {}
class_counts_detail = {}
total_images = total_train = total_val = total_test = 0
# 获取所有类别(以 train 为主,兼容 val/test 独有类别)
all_class_names = set()
for folder in train_dir.iterdir():
if folder.is_dir():
all_class_names.add(folder.name)
if val_dir.exists():
for folder in val_dir.iterdir():
if folder.is_dir():
all_class_names.add(folder.name)
if test_dir.exists():
for folder in test_dir.iterdir():
if folder.is_dir():
all_class_names.add(folder.name)
image_exts = {'.jpg', '.jpeg', '.png'}
for class_name in sorted(all_class_names):
def _count(base_dir):
d = base_dir / class_name
if not d.exists():
return 0
return sum(1 for f in d.iterdir()
if f.is_file() and f.suffix.lower() in image_exts)
train_count = _count(train_dir)
val_count = _count(val_dir)
test_count = _count(test_dir)
total = train_count + val_count + test_count
class_counts[class_name] = total
class_counts_detail[class_name] = {
'train': train_count,
'val': val_count,
'test': test_count,
'total': total
}
total_images += total
total_train += train_count
total_val += val_count
total_test += test_count
# 按数量降序排列
sorted_classes = sorted(class_counts.items(), key=lambda x: x[1], reverse=True)
min_threshold = 100
insufficient_count = 0
# ── 控制台输出 ──────────────────────────────────────────────
print(f"\n总类别数: {len(class_counts)}")
print(f"总图片数: {total_images} (训练集: {total_train}, 验证集: {total_val}, 测试集: {total_test})")
print(f"平均每类: {total_images // len(class_counts) if len(class_counts) > 0 else 0}\n")
print("-"*100)
print(f"{'类别名称':<50} {'总计':<8} {'训练':<8} {'验证':<8} {'测试':<8} {'状态':<10}")
print("-"*100)
for class_name, count in sorted_classes:
detail = class_counts_detail[class_name]
status = "⚠样本不足" if count < min_threshold else ""
if count < min_threshold:
insufficient_count += 1
print(f"{class_name:<50} {detail['total']:<8} {detail['train']:<8} "
f"{detail['val']:<8} {detail['test']:<8} {status:<10}")
print("-"*100)
print(f"\n样本不足的类别数(<{min_threshold}张): {insufficient_count}")
print(f"样本充足的类别数(>={min_threshold}张): {len(class_counts) - insufficient_count}")
# ── 可选:保存到 txt 文件 ────────────────────────────────────
if output_file:
output_path = Path(output_file)
with open(output_path, 'w', encoding='utf-8') as f:
f.write("="*100 + "\n")
f.write("每个类别的图片数量统计(包含train/val/test\n")
f.write("="*100 + "\n\n")
f.write(f"总类别数: {len(class_counts)}\n")
f.write(f"总图片数: {total_images} (训练集: {total_train}, 验证集: {total_val}, 测试集: {total_test})\n")
f.write(f"平均每类: {total_images // len(class_counts) if len(class_counts) > 0 else 0}\n\n")
f.write("-"*100 + "\n")
f.write(f"{'类别名称':<50} {'总计':<8} {'训练':<8} {'验证':<8} {'测试':<8} {'状态':<10}\n")
f.write("-"*100 + "\n")
for class_name, count in sorted_classes:
detail = class_counts_detail[class_name]
status = "⚠样本不足" if count < min_threshold else ""
f.write(f"{class_name:<50} {detail['total']:<8} {detail['train']:<8} "
f"{detail['val']:<8} {detail['test']:<8} {status:<10}\n")
f.write("-"*100 + "\n")
f.write(f"\n样本不足的类别数(<{min_threshold}张): {insufficient_count}\n")
f.write(f"样本充足的类别数(>={min_threshold}张): {len(class_counts) - insufficient_count}\n")
print(f"\n✓ 统计结果已保存到: {output_path}")
# ── 可选:导出 Excel ─────────────────────────────────────────
if export_excel:
export_to_excel(
sorted_classes = sorted_classes,
class_counts_detail = class_counts_detail,
class_counts = class_counts,
total_images = total_images,
total_train = total_train,
total_val = total_val,
total_test = total_test,
min_threshold = min_threshold,
dataset_root = dataset_root,
)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='统计每个类别的图片数量')
parser.add_argument(
'--dataset', type=str,
default='dataset/WholeIngredientRecognition',
help='数据集根目录(默认: dataset/WholeIngredientRecognition'
)
parser.add_argument(
'--output', type=str, default=None,
help='输出 txt 文件路径(可选)'
)
parser.add_argument(
'--excel', action='store_true',default= '--excel',
help='是否同时导出 Excel 文件(可直接复制粘贴到飞书云文档)'
)
args = parser.parse_args()
# 获取项目根目录
script_dir = Path(__file__).parent
project_root = script_dir.parent
# 处理相对路径
if not os.path.isabs(args.dataset):
dataset_path = project_root / args.dataset
else:
dataset_path = Path(args.dataset)
count_images_by_class(str(dataset_path), args.output, export_excel=args.excel)
+1 -2
View File
@@ -270,7 +270,7 @@ class MySQLManager:
month_start = today_start.replace(day=1)
month_count = base_query.filter(Model.create_time >= month_start).count()
# TOP 10物品统计
# 全部物品统计(按数量降序)
from sqlalchemy import func
top_items = session.query(
Model.goods_name,
@@ -289,7 +289,6 @@ class MySQLManager:
top_items = top_items.group_by(Model.goods_name)\
.order_by(func.count(Model.id).desc())\
.limit(10)\
.all()
return {
+334
View File
@@ -0,0 +1,334 @@
"""
无GUI数据集构建脚本
流程:按日期查询MySQL -> 下载到train按类别目录 -> 按70/15/15分割为train/val/test
"""
import argparse
import json
import os
import random
import re
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import requests
# 允许从 data_management 直接运行
CURRENT_DIR = Path(__file__).resolve().parent
if str(CURRENT_DIR) not in sys.path:
sys.path.append(str(CURRENT_DIR))
from database import MySQLManager # noqa: E402
from split_dataset import split_dataset # noqa: E402
IMAGE_EXT_DEFAULT = ".jpg"
VALID_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
@dataclass
class DownloadResult:
success: bool
goods_name: str
image_url: str
saved_path: Optional[str] = None
error_message: Optional[str] = None
class SimpleDownloader:
"""无状态批量下载器(带重试和跳过)"""
def __init__(self, timeout: int = 30, max_retries: int = 2, workers: int = 8):
self.timeout = timeout
self.max_retries = max_retries
self.workers = workers
self._lock = threading.Lock()
self._seen_targets = set()
@staticmethod
def _sanitize_name(name: str) -> str:
name = (name or "未知类别").strip()
name = re.sub(r"[\\/:*?\"<>|]", "_", name)
name = re.sub(r"\s+", "_", name)
return name[:80] if len(name) > 80 else name
@staticmethod
def _infer_ext_from_url(image_url: str) -> str:
base = image_url.split("?")[0]
ext = os.path.splitext(base)[1].lower()
return ext if ext in VALID_EXTS else IMAGE_EXT_DEFAULT
def _build_file_path(self, train_root: Path, row: Dict) -> Path:
cls_name = self._sanitize_name(row.get("goods_name", "未知类别"))
class_dir = train_root / cls_name
class_dir.mkdir(parents=True, exist_ok=True)
create_time = row.get("create_time", "")
dt_tag = re.sub(r"[^0-9]", "", create_time) if create_time else ""
dt_tag = dt_tag[:14] if dt_tag else datetime.now().strftime("%Y%m%d%H%M%S")
data_id = row.get("id", "na")
ext = self._infer_ext_from_url(row.get("image_url", ""))
filename = f"img_{dt_tag}_{data_id}{ext}"
target = class_dir / filename
# 文件名冲突兜底
if target.exists():
suffix = 1
while True:
candidate = class_dir / f"img_{dt_tag}_{data_id}_{suffix}{ext}"
if not candidate.exists():
target = candidate
break
suffix += 1
return target
def _download_one(self, row: Dict, train_root: Path, skip_existing: bool) -> DownloadResult:
goods_name = row.get("goods_name", "未知类别")
image_url = row.get("image_url", "")
if not image_url:
return DownloadResult(False, goods_name, image_url, error_message="空图片URL")
target = self._build_file_path(train_root, row)
target_key = str(target)
with self._lock:
if target_key in self._seen_targets:
return DownloadResult(True, goods_name, image_url, saved_path=target_key)
self._seen_targets.add(target_key)
if skip_existing and target.exists():
return DownloadResult(True, goods_name, image_url, saved_path=str(target))
last_error = None
for _ in range(self.max_retries + 1):
try:
response = requests.get(image_url, timeout=self.timeout, stream=True)
response.raise_for_status()
with open(target, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
return DownloadResult(True, goods_name, image_url, saved_path=str(target))
except Exception as e:
last_error = str(e)
time.sleep(0.3)
return DownloadResult(False, goods_name, image_url, error_message=last_error)
def download_batch(self, rows: List[Dict], train_root: Path, skip_existing: bool = True) -> Tuple[int, int, List[Dict]]:
success = 0
failed = 0
failures: List[Dict] = []
with ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = [executor.submit(self._download_one, row, train_root, skip_existing) for row in rows]
for future in as_completed(futures):
result = future.result()
if result.success:
success += 1
else:
failed += 1
failures.append(
{
"goods_name": result.goods_name,
"image_url": result.image_url,
"error": result.error_message,
}
)
return success, failed, failures
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="按日期下载并自动分割70/15/15数据集")
parser.add_argument("--start-date", help="开始日期,格式 YYYY-MM-DD", default="2026-04-08")
parser.add_argument("--end-date", help="结束日期,格式 YYYY-MM-DD", default="2026-04-22")
parser.add_argument(
"--output-dir",
help="输出目录(每次手动指定)",
default=r"D:\MyProjects\PythonProjects\FoodClassifier\dataset\item\20260422",
)
parser.add_argument("--datasource", default="食材", choices=["菜品", "食材", "半加工"], help="数据源")
parser.add_argument("--page-size", type=int, default=500, help="分页大小,默认500")
parser.add_argument("--workers", type=int, default=8, help="下载并发数,默认8")
parser.add_argument("--timeout", type=int, default=30, help="下载超时时间(秒)")
parser.add_argument("--max-retries", type=int, default=2, help="下载失败重试次数")
parser.add_argument("--train-ratio", type=float, default=0.70, help="训练集比例")
parser.add_argument("--val-ratio", type=float, default=0.15, help="验证集比例")
parser.add_argument("--test-ratio", type=float, default=0.15, help="测试集比例")
parser.add_argument("--seed", type=int, default=42, help="随机种子")
parser.add_argument("--min-samples", type=int, default=10, help="最小样本阈值")
parser.add_argument("--mark-insufficient", action="store_true", help="是否标记样本不足类别")
parser.add_argument("--max-total", type=int, default=120, help="单类上限,默认120")
parser.add_argument("--skip-existing", action="store_true", help="已存在文件直接跳过")
return parser.parse_args()
def parse_date(text: str) -> datetime:
return datetime.strptime(text, "%Y-%m-%d")
def fetch_all_rows(
mysql: MySQLManager,
datasource: str,
start_date: datetime,
end_date: datetime,
page_size: int,
) -> List[Dict]:
rows: List[Dict] = []
page = 1
while True:
page_data, total = mysql.query_images(
datasource=datasource,
start_date=start_date,
end_date=end_date,
goods_name=None,
equipment_code=None,
page=page,
page_size=page_size,
)
if not page_data:
break
rows.extend(page_data)
print(f"[Query] page={page}, fetched={len(page_data)}, accumulated={len(rows)}, total={total}")
if len(rows) >= total:
break
page += 1
return rows
def save_json(path: Path, obj: Dict):
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, indent=2)
def main():
args = parse_args()
start_date = parse_date(args.start_date)
end_date = parse_date(args.end_date)
if end_date < start_date:
raise ValueError("end_date 不能早于 start_date")
ratio_sum = args.train_ratio + args.val_ratio + args.test_ratio
if abs(ratio_sum - 1.0) > 1e-6:
raise ValueError(f"比例和必须为1.0,当前为 {ratio_sum}")
random.seed(args.seed)
output_dir = Path(args.output_dir).resolve()
train_dir = output_dir / "train"
val_dir = output_dir / "val"
test_dir = output_dir / "test"
logs_dir = output_dir / "logs"
train_dir.mkdir(parents=True, exist_ok=True)
val_dir.mkdir(parents=True, exist_ok=True)
test_dir.mkdir(parents=True, exist_ok=True)
logs_dir.mkdir(parents=True, exist_ok=True)
print("=" * 88)
print("数据集自动构建开始")
print(f"datasource={args.datasource}")
print(f"date_range=[{args.start_date}, {args.end_date}]")
print(f"output_dir={output_dir}")
print(f"ratio=train:{args.train_ratio}, val:{args.val_ratio}, test:{args.test_ratio}")
print("=" * 88)
mysql = MySQLManager()
rows = fetch_all_rows(mysql, args.datasource, start_date, end_date, args.page_size)
print(f"[Query] total rows fetched: {len(rows)}")
if not rows:
summary = {
"status": "empty",
"datasource": args.datasource,
"start_date": args.start_date,
"end_date": args.end_date,
"message": "查询结果为空,没有可下载数据",
}
save_json(logs_dir / "run_summary.json", summary)
print("没有数据,任务结束")
return
downloader = SimpleDownloader(
timeout=args.timeout,
max_retries=args.max_retries,
workers=args.workers,
)
success_count, failed_count, failures = downloader.download_batch(
rows=rows,
train_root=train_dir,
skip_existing=args.skip_existing,
)
print(f"[Download] success={success_count}, failed={failed_count}")
if failures:
save_json(logs_dir / "download_failures.json", {"failures": failures})
# 调用现有分割逻辑
split_dataset(
dataset_root=str(output_dir),
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
test_ratio=args.test_ratio,
min_samples_threshold=args.min_samples,
mark_insufficient=args.mark_insufficient,
max_total=args.max_total,
seed=args.seed,
)
summary = {
"status": "done",
"datasource": args.datasource,
"start_date": args.start_date,
"end_date": args.end_date,
"output_dir": str(output_dir),
"total_rows": len(rows),
"download_success": success_count,
"download_failed": failed_count,
"ratios": {
"train": args.train_ratio,
"val": args.val_ratio,
"test": args.test_ratio,
},
"seed": args.seed,
"page_size": args.page_size,
"workers": args.workers,
"skip_existing": args.skip_existing,
}
save_json(logs_dir / "run_summary.json", summary)
print("=" * 88)
print("数据集自动构建完成")
print(f"summary: {logs_dir / 'run_summary.json'}")
if failures:
print(f"failures: {logs_dir / 'download_failures.json'}")
print("=" * 88)
if __name__ == "__main__":
main()
+245
View File
@@ -0,0 +1,245 @@
"""
检查并删除重复的照片
通过文件内容(MD5)或文件名来识别重复
"""
import os
import hashlib
from pathlib import Path
from collections import defaultdict
import shutil
def calculate_md5(file_path: Path) -> str:
"""计算文件的MD5值"""
hash_md5 = hashlib.md5()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
return hash_md5.hexdigest()
def find_duplicates_by_content(dataset_root: str, dry_run: bool = True):
"""
通过文件内容(MD5)查找重复照片
Args:
dataset_root: 数据集根目录
dry_run: 是否只是预览,不实际删除
"""
dataset_root = Path(dataset_root)
print("="*80)
print("检查重复照片(基于文件内容MD5")
print("="*80)
print(f"数据集根目录: {dataset_root}")
print(f"模式: {'预览模式(不实际删除)' if dry_run else '执行模式'}")
print("="*80 + "\n")
# 存储所有文件的MD5值
md5_to_files = defaultdict(list)
# 遍历所有图片文件
image_extensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']
total_files = 0
print("正在计算文件MD5值...")
for root, dirs, files in os.walk(dataset_root):
for file in files:
if any(file.endswith(ext) for ext in image_extensions):
file_path = Path(root) / file
try:
md5_hash = calculate_md5(file_path)
md5_to_files[md5_hash].append(file_path)
total_files += 1
if total_files % 100 == 0:
print(f" 已处理: {total_files} 个文件...")
except Exception as e:
print(f" ⚠ 处理文件失败 {file_path}: {e}")
print(f"\n✓ 共处理 {total_files} 个文件\n")
# 找出重复的文件
duplicates = {md5: files for md5, files in md5_to_files.items() if len(files) > 1}
if not duplicates:
print("✓ 没有发现重复的照片")
return
print(f"发现 {len(duplicates)} 组重复照片:\n")
total_duplicates = 0
total_to_delete = 0
for md5_hash, files in sorted(duplicates.items(), key=lambda x: len(x[1]), reverse=True):
# 按路径排序,保留第一个(通常保留train目录的)
files_sorted = sorted(files)
# 优先保留train目录的文件
keep_file = None
for f in files_sorted:
if 'train' in str(f):
keep_file = f
break
if keep_file is None:
keep_file = files_sorted[0] # 如果没有train目录的,保留第一个
files_to_delete = [f for f in files_sorted if f != keep_file]
print(f"MD5: {md5_hash[:8]}...")
print(f" 保留: {keep_file}")
for f in files_to_delete:
print(f" 删除: {f}")
total_duplicates += len(files)
total_to_delete += len(files_to_delete)
# 实际删除
if not dry_run:
for f in files_to_delete:
try:
f.unlink()
print(f" ✓ 已删除: {f.name}")
except Exception as e:
print(f" ✗ 删除失败: {e}")
print()
print("="*80)
print("统计:")
print("="*80)
print(f"重复文件组数: {len(duplicates)}")
print(f"重复文件总数: {total_duplicates}")
print(f"将删除文件数: {total_to_delete}")
print(f"将保留文件数: {len(duplicates)}")
if dry_run:
print("\n使用 --execute 参数来实际执行删除操作")
else:
print("\n✓ 删除完成!")
def find_duplicates_by_name(dataset_root: str, dry_run: bool = True):
"""
通过文件名查找重复照片(同一文件名出现在多个目录)
Args:
dataset_root: 数据集根目录
dry_run: 是否只是预览,不实际删除
"""
dataset_root = Path(dataset_root)
print("="*80)
print("检查重复照片(基于文件名)")
print("="*80)
print(f"数据集根目录: {dataset_root}")
print(f"模式: {'预览模式(不实际删除)' if dry_run else '执行模式'}")
print("="*80 + "\n")
# 存储文件名到文件路径的映射
name_to_files = defaultdict(list)
# 遍历所有图片文件
image_extensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']
for root, dirs, files in os.walk(dataset_root):
for file in files:
if any(file.endswith(ext) for ext in image_extensions):
file_path = Path(root) / file
name_to_files[file].append(file_path)
# 找出重复的文件名
duplicates = {name: files for name, files in name_to_files.items() if len(files) > 1}
if not duplicates:
print("✓ 没有发现同名重复的照片")
return
print(f"发现 {len(duplicates)} 组同名重复照片:\n")
total_to_delete = 0
for file_name, files in sorted(duplicates.items(), key=lambda x: len(x[1]), reverse=True):
# 按路径排序,优先保留train目录的
files_sorted = sorted(files)
keep_file = None
for f in files_sorted:
if 'train' in str(f):
keep_file = f
break
if keep_file is None:
keep_file = files_sorted[0]
files_to_delete = [f for f in files_sorted if f != keep_file]
print(f"文件名: {file_name}")
print(f" 保留: {keep_file}")
for f in files_to_delete:
print(f" 删除: {f}")
total_to_delete += len(files_to_delete)
# 实际删除
if not dry_run:
for f in files_to_delete:
try:
f.unlink()
print(f" ✓ 已删除: {f.name}")
except Exception as e:
print(f" ✗ 删除失败: {e}")
print()
print("="*80)
print(f"将删除文件数: {total_to_delete}")
if dry_run:
print("\n使用 --execute 参数来实际执行删除操作")
else:
print("\n✓ 删除完成!")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='检查并删除重复的照片')
parser.add_argument('--dataset', type=str,
default='dataset/WholeIngredientRecognition',
help='数据集根目录(默认: dataset/WholeIngredientRecognition')
parser.add_argument('--method', type=str,
choices=['content', 'name', 'both'],
default='both',
help='检查方法: content(基于MD5), name(基于文件名), both(两种都检查)')
parser.add_argument('--dry-run', action='store_true',
help='预览模式,不实际删除')
parser.add_argument('--execute', action='store_true',
help='实际执行删除(需要明确指定)')
args = parser.parse_args()
# 获取项目根目录
script_dir = Path(__file__).parent
project_root = script_dir.parent
# 处理相对路径
if not os.path.isabs(args.dataset):
if args.dataset.startswith('../'):
dataset_path = (script_dir / args.dataset).resolve()
else:
dataset_path = (project_root / args.dataset).resolve()
else:
dataset_path = Path(args.dataset).resolve()
# 如果没有指定execute,默认是dry-run
if not args.execute and not args.dry_run:
print("⚠ 警告: 默认是预览模式,不会实际删除")
print("使用 --execute 参数来实际执行删除操作\n")
args.dry_run = True
if args.method in ['content', 'both']:
find_duplicates_by_content(str(dataset_path), dry_run=args.dry_run)
if args.method == 'both':
print("\n" + "="*80 + "\n")
if args.method in ['name', 'both']:
find_duplicates_by_name(str(dataset_path), dry_run=args.dry_run)
+367
View File
@@ -0,0 +1,367 @@
"""cui
数据集分割脚本
1. 将train目录下的图片按70:15:15分割到train/val/test
2. 对样本量过少的类别进行标记
"""
import os
import shutil
import random
from pathlib import Path
from typing import List, Tuple
def split_dataset(
dataset_root: str,
train_ratio: float = 0.70,
val_ratio: float = 0.15,
test_ratio: float = 0.15,
min_samples_threshold: int = 10,
mark_insufficient: bool = True,
max_total: int = 120,
seed: int = 42
):
"""
分割数据集
Args:
dataset_root: 数据集根目录(包含train目录)
train_ratio: 训练集比例(默认0.70
val_ratio: 验证集比例(默认0.15
test_ratio: 测试集比例(默认0.15
min_samples_threshold: 最小样本数阈值,低于此值的类别会被标记(默认10)
mark_insufficient: 是否在文件夹名称后添加"样本数太少"标记(默认True
max_total: 如果总数超过100,限制到max_total张(默认120
seed: 随机种子(默认42)
"""
# 验证比例
if abs(train_ratio + val_ratio + test_ratio - 1.0) > 0.001:
raise ValueError(f"比例总和必须为1.0,当前为: {train_ratio + val_ratio + test_ratio}")
dataset_root = Path(dataset_root)
train_dir = dataset_root / 'train'
val_dir = dataset_root / 'val'
test_dir = dataset_root / 'test'
if not train_dir.exists():
raise ValueError(f"训练目录不存在: {train_dir}")
# 创建val和test目录
val_dir.mkdir(parents=True, exist_ok=True)
test_dir.mkdir(parents=True, exist_ok=True)
# 设置随机种子
random.seed(seed)
print("="*80)
print(f"数据集分割工具")
print("="*80)
print(f"数据集根目录: {dataset_root}")
print(f"分割比例: 训练集={train_ratio*100:.0f}%, 验证集={val_ratio*100:.0f}%, 测试集={test_ratio*100:.0f}%")
print(f"最小样本数阈值: {min_samples_threshold}")
print(f"样本不足标记: {'启用' if mark_insufficient else '禁用'}")
print("="*80 + "\n")
# 检查val和test目录是否已有数据
val_has_data = val_dir.exists() and any(val_dir.iterdir())
test_has_data = test_dir.exists() and any(test_dir.iterdir())
if val_has_data or test_has_data:
print("⚠ 检测到验证集或测试集已有数据")
print("将检查所有类别的比例,如果不符合70:15:15将重新分配\n")
# 获取所有类别文件夹
class_folders = [f for f in train_dir.iterdir() if f.is_dir()]
total_classes = len(class_folders)
print(f"找到 {total_classes} 个类别\n")
stats = {
'total_classes': total_classes,
'processed_classes': 0,
'skipped_classes': 0,
'sufficient_samples': 0,
'insufficient_samples': 0,
'total_images': 0,
'train_images': 0,
'val_images': 0,
'test_images': 0,
'marked_classes': []
}
# 处理每个类别
for class_folder in sorted(class_folders):
class_name = class_folder.name
# 收集所有图片(从train/val/test
train_images = []
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
train_images.extend(list(class_folder.glob(ext)))
val_class_dir = val_dir / class_name
val_images = []
if val_class_dir.exists():
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
val_images.extend(list(val_class_dir.glob(ext)))
test_class_dir = test_dir / class_name
test_images = []
if test_class_dir.exists():
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
test_images.extend(list(test_class_dir.glob(ext)))
# 合并所有图片
all_images = train_images + val_images + test_images
num_images = len(all_images)
stats['total_images'] += num_images
if num_images == 0:
print(f"{class_name}: 0张图片,跳过")
continue
# 检查当前比例是否正确
current_train = len(train_images)
current_val = len(val_images)
current_test = len(test_images)
# 计算期望的数量
expected_train = int(num_images * train_ratio)
expected_val = int(num_images * val_ratio)
expected_test = num_images - expected_train - expected_val
# 检查比例是否接近(允许1-2张的误差)
ratio_correct = (
abs(current_train - expected_train) <= 2 and
abs(current_val - expected_val) <= 2 and
abs(current_test - expected_test) <= 2
)
# 如果总数超过100,即使比例正确也需要限制
if ratio_correct and num_images > 0 and num_images <= 100:
# 比例正确且总数<=100,跳过
stats['skipped_classes'] += 1
is_insufficient = num_images < min_samples_threshold
if is_insufficient:
stats['insufficient_samples'] += 1
else:
stats['sufficient_samples'] += 1
print(f"{class_name}: 总数={num_images}, 比例正确 (train={current_train}, val={current_val}, test={current_test}),跳过")
continue
# 如果比例正确但总数>100,需要限制
if ratio_correct and num_images > 100:
print(f"处理 {class_name}: 总数={num_images} > 100, 比例正确但需要限制到{max_total}")
stats['processed_classes'] += 1
# 随机选择max_total张
random.shuffle(all_images)
image_files = all_images[:max_total]
num_images = len(image_files)
# 重新计算期望数量
expected_train = int(num_images * train_ratio)
expected_val = int(num_images * val_ratio)
expected_test = num_images - expected_train - expected_val
print(f" 将重新分配为: train={expected_train}, val={expected_val}, test={expected_test}")
elif not ratio_correct:
# 比例不正确,需要重新分配
stats['processed_classes'] += 1
print(f"处理 {class_name}: 总数={num_images}, 当前比例不正确 (train={current_train}, val={current_val}, test={current_test})")
# 如果总数超过100,限制到max_total
if num_images > 100:
print(f" 总数={num_images} > 100, 限制到{max_total}")
random.shuffle(all_images)
image_files = all_images[:max_total]
num_images = len(image_files)
# 重新计算期望数量
expected_train = int(num_images * train_ratio)
expected_val = int(num_images * val_ratio)
expected_test = num_images - expected_train - expected_val
else:
image_files = all_images
print(f" 将重新分配为: train={expected_train}, val={expected_val}, test={expected_test}")
# 检查样本数是否足够
is_insufficient = num_images < min_samples_threshold
if is_insufficient:
stats['insufficient_samples'] += 1
if mark_insufficient:
# 标记样本不足的类别
marked_name = f"{class_name}_样本数太少"
stats['marked_classes'].append((class_name, num_images, marked_name))
else:
stats['sufficient_samples'] += 1
# 确定最终的类别名称(如果需要标记)
final_class_name = f"{class_name}_样本数太少" if (is_insufficient and mark_insufficient) else class_name
# 如果还没打乱(总数<=100的情况),现在打乱
if num_images == len(all_images):
random.shuffle(image_files)
# 计算分割点
n_train = int(num_images * train_ratio)
n_val = int(num_images * val_ratio)
# 剩余的全部给test,确保所有图片都被分配
# 分割图片(重新分配)
new_train_images = image_files[:n_train]
new_val_images = image_files[n_train:n_train + n_val]
new_test_images = image_files[n_train + n_val:]
# 创建目标目录
train_target = train_dir / final_class_name
val_target = val_dir / final_class_name
test_target = test_dir / final_class_name
train_target.mkdir(exist_ok=True)
val_target.mkdir(exist_ok=True)
test_target.mkdir(exist_ok=True)
# 先移动图片到正确的位置(在删除之前)
moved_to_train = 0
for img in new_train_images:
if not img.exists():
continue
target = train_target / img.name
if img.parent != train_target:
if target.exists():
target.unlink()
try:
shutil.move(str(img), str(target))
moved_to_train += 1
except Exception as e:
print(f" ⚠ 移动失败 {img}: {e}")
moved_to_val = 0
for img in new_val_images:
if not img.exists():
continue
target = val_target / img.name
if img.parent != val_target:
if target.exists():
target.unlink()
try:
shutil.move(str(img), str(target))
moved_to_val += 1
except Exception as e:
print(f" ⚠ 移动失败 {img}: {e}")
moved_to_test = 0
for img in new_test_images:
if not img.exists():
continue
target = test_target / img.name
if img.parent != test_target:
if target.exists():
target.unlink()
try:
shutil.move(str(img), str(target))
moved_to_test += 1
except Exception as e:
print(f" ⚠ 移动失败 {img}: {e}")
# 然后清理所有目录(删除不在新分配列表中的图片)
# 收集所有原始图片路径
original_images = set(train_images + val_images + test_images)
new_images = set(new_train_images + new_val_images + new_test_images)
images_to_delete = original_images - new_images
for img in images_to_delete:
if img.exists():
try:
img.unlink()
except Exception as e:
pass
# 处理样本不足标记(如果需要)
if mark_insufficient and is_insufficient:
# 需要重命名train目录的文件夹
if class_folder != train_target:
# 如果原文件夹还存在且不为空,需要移动剩余文件
if class_folder.exists() and any(class_folder.iterdir()):
for item in class_folder.iterdir():
if item.is_file():
shutil.move(str(item), train_target / item.name)
# 删除原文件夹(如果为空)
try:
if class_folder.exists() and not any(class_folder.iterdir()):
class_folder.rmdir()
except Exception as e:
print(f" 警告: 删除原文件夹失败: {e}")
# 更新统计
stats['train_images'] += len(new_train_images)
stats['val_images'] += len(new_val_images)
stats['test_images'] += len(new_test_images)
# 打印进度
status = "⚠样本不足" if is_insufficient else ""
print(f"{status} {class_name}: 总计={num_images}, 训练={len(new_train_images)}, 验证={len(new_val_images)}, 测试={len(new_test_images)}")
# 打印总结
print("\n" + "="*80)
print("分割完成!")
print("="*80)
print(f"总类别数: {stats['total_classes']}")
print(f"已处理: {stats['processed_classes']} 个类别(比例不正确,已重新分配)")
print(f"已跳过: {stats['skipped_classes']} 个类别(比例正确)")
print(f"样本充足: {stats['sufficient_samples']} 个类别")
print(f"样本不足: {stats['insufficient_samples']} 个类别")
print(f"\n总图片数: {stats['total_images']}")
print(f"训练集: {stats['train_images']} 张 ({stats['train_images']/stats['total_images']*100:.1f}%)")
print(f"验证集: {stats['val_images']} 张 ({stats['val_images']/stats['total_images']*100:.1f}%)")
print(f"测试集: {stats['test_images']} 张 ({stats['test_images']/stats['total_images']*100:.1f}%)")
if stats['marked_classes']:
print(f"\n已标记的类别(样本数 < {min_samples_threshold}:")
for old_name, count, new_name in stats['marked_classes']:
print(f" {old_name} ({count}张) -> {new_name}")
print("\n✓ 完成!")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='数据集分割工具')
parser.add_argument('--dataset', type=str,
default='dataset/WholeIngredientRecognition',
help='数据集根目录(默认: dataset/WholeIngredientRecognition')
parser.add_argument('--train-ratio', type=float, default=0.70,
help='训练集比例(默认: 0.70')
parser.add_argument('--val-ratio', type=float, default=0.15,
help='验证集比例(默认: 0.15')
parser.add_argument('--test-ratio', type=float, default=0.15,
help='测试集比例(默认: 0.15')
parser.add_argument('--min-samples', type=int, default=10,
help='最小样本数阈值,低于此值会被标记(默认: 10)')
parser.add_argument('--no-mark', action='store_true',
help='不标记样本不足的类别')
parser.add_argument('--seed', type=int, default=42,
help='随机种子(默认: 42')
parser.add_argument('--max-total', type=int, default=120,
help='如果总数超过100,限制到max_total张(默认: 120')
args = parser.parse_args()
# 获取项目根目录
script_dir = Path(__file__).parent
project_root = script_dir.parent
# 处理相对路径
if not os.path.isabs(args.dataset):
dataset_path = project_root / args.dataset
else:
dataset_path = Path(args.dataset)
split_dataset(
dataset_root=str(dataset_path),
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
test_ratio=args.test_ratio,
min_samples_threshold=args.min_samples,
mark_insufficient=not args.no_mark,
max_total=args.max_total,
seed=args.seed
)
+6
View File
@@ -276,3 +276,9 @@ class FilterPanel(ctk.CTkFrame):
self.query_button.configure(state="disabled", text="查询中...")
else:
self.query_button.configure(state="normal", text="查询")
def set_goods_name_and_query(self, goods_name: str):
"""从排行榜点击物品名称时,填入筛选框并触发查询"""
self.goods_search_entry.delete(0, 'end')
self.goods_search_entry.insert(0, goods_name)
self._on_query()
+27 -8
View File
@@ -15,6 +15,7 @@ class StatisticsPanel(ctk.CTkFrame):
on_download_history_callback: Callable,
on_export_callback: Callable,
on_settings_callback: Callable,
on_item_click_callback: Optional[Callable] = None,
**kwargs
):
super().__init__(parent, **kwargs)
@@ -23,6 +24,7 @@ class StatisticsPanel(ctk.CTkFrame):
self.on_download_history_callback = on_download_history_callback
self.on_export_callback = on_export_callback
self.on_settings_callback = on_settings_callback
self.on_item_click_callback = on_item_click_callback
self._create_widgets()
@@ -130,13 +132,13 @@ class StatisticsPanel(ctk.CTkFrame):
separator2 = ctk.CTkFrame(time_frame, height=2)
separator2.pack(fill="x", pady=10, padx=15)
# ==================== TOP 10物品排行 ====================
# ==================== 物品排行(全部) ====================
top_frame = ctk.CTkFrame(self)
top_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10))
top_title = ctk.CTkLabel(
top_frame,
text="TOP 10 物品排行",
text="物品排行(全部)",
font=("Arial", 12, "bold")
)
top_title.pack(pady=(10, 5), padx=10)
@@ -212,7 +214,7 @@ class StatisticsPanel(ctk.CTkFrame):
- today_count: 今日数量
- week_count: 本周数量
- month_count: 本月数量
- top_items: TOP 10列表 [{'name': str, 'count': int}, ...]
- top_items: 全部物品列表 [{'name': str, 'count': int}, ...]
"""
# 查询结果
total_count = stats.get('total_count', 0)
@@ -232,7 +234,7 @@ class StatisticsPanel(ctk.CTkFrame):
self._update_top_items(stats.get('top_items', []))
def _update_top_items(self, top_items: list):
"""更新TOP 10排行榜"""
"""更新物品排行(全部)"""
# 清空现有内容
for widget in self.top_scroll.winfo_children():
widget.destroy()
@@ -247,7 +249,7 @@ class StatisticsPanel(ctk.CTkFrame):
no_data_label.pack(pady=10)
return
# 显示TOP 10
# 显示全部物品
for i, item in enumerate(top_items, 1):
item_frame = ctk.CTkFrame(self.top_scroll)
item_frame.pack(fill="x", pady=2, padx=5)
@@ -261,15 +263,32 @@ class StatisticsPanel(ctk.CTkFrame):
)
rank_label.pack(side="left", padx=(5, 2))
# 物品名称
# 物品名称(可点击筛选)
name_label = ctk.CTkLabel(
item_frame,
text=item['name'],
font=("Arial", 10),
anchor="w"
anchor="w",
cursor="hand2" # 鼠标悬浮时显示手型
)
name_label.pack(side="left", fill="x", expand=True, padx=5)
# 绑定点击与悬浮事件
if self.on_item_click_callback:
item_name = item['name']
name_label.bind(
"<Button-1>",
lambda e, name=item_name: self.on_item_click_callback(name)
)
name_label.bind(
"<Enter>",
lambda e, lbl=name_label: lbl.configure(text_color="#4FC3F7")
)
name_label.bind(
"<Leave>",
lambda e, lbl=name_label: lbl.configure(text_color=ctk.ThemeManager.theme["CTkLabel"]["text_color"])
)
# 数量
count_label = ctk.CTkLabel(
item_frame,
@@ -291,6 +310,6 @@ class StatisticsPanel(ctk.CTkFrame):
self.week_label.configure(text="本周: 0 张")
self.month_label.configure(text="本月: 0 张")
# 清空TOP 10
# 清空全部物品排行
for widget in self.top_scroll.winfo_children():
widget.destroy()
-40
View File
@@ -1,40 +0,0 @@
import faiss
import numpy as np
# 数据归一化函数
def normalize_vectors(vectors):
"""对向量进行L2归一化"""
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
# 避免除零
norms = np.where(norms == 0, 1, norms)
return vectors / norms
data = np.array([[2, 3], [2, 4], [3, 7]], dtype='float32')
# 归一化数据
data_normalized = normalize_vectors(data)
print("原始数据:")
print(data)
print("归一化后数据:")
print(data_normalized)
# 普通索引
# base_index = faiss.IndexFlatL2(2)
base_index = faiss.IndexFlatIP(2)
# 包一层 IDMap
index = faiss.IndexIDMap(base_index)
# 指定 ID
ids = np.array([101, 102, 103]) # 自定义 ID
# index.add_with_ids(data, ids)
index.add_with_ids(data_normalized, ids)
# 查询
query = np.array([[3, 4.5]], dtype='float32')
query_normalized = normalize_vectors(query)
# D, I = index.search(query, k=2)
D, I = index.search(query_normalized, k=2)
print(D)
print(I) # 可能输出 [[101 102]]
-20
View File
@@ -1,20 +0,0 @@
import faiss
import numpy as np
# 建一个 2 维向量的 L2 索引
index = faiss.IndexFlatL2(2)
print(index.ntotal) # 初始是 0
# 插入 5 个向量
data = np.random.rand(5, 2).astype("float32")
index.add(data)
print(index.ntotal) # 现在是 5
# 再插入 3 个
more_data = np.random.rand(3, 2).astype("float32")
index.add(more_data)
print(index.ntotal) # 现在是 8
-773
View File
@@ -1,773 +0,0 @@
import random
import torch
# torchvision是PyTorch中专门用来处理图像的库,使用了torchvision就非常方便
import torchvision
# transforms是torchvision库用来对图像进行变换的
import torchvision.transforms as transforms
#time 是Python自带的库,引入它主要是用于记录训练时间
import time
import sys
from torch.utils.data import random_split
import numpy as np
from tqdm import tqdm
from colorama import Fore, Style, Back
import os
from torchvision.models import resnet18,resnet34,resnet50,resnet101
import matplotlib.pyplot as plt
# print('火山引擎,我来了')
# 记录开始时间
start_time = time.time()
# 加上设备,然后准备放到赠送的线上GPU上训练
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])
transform = transforms.Compose([transforms.ToTensor(),
transforms.Normalize((0.485, 0.456, 0.406), (0.229, 0.224, 0.225)),
transforms.RandomHorizontalFlip(), #随机水平翻转
transforms.RandomCrop(32, padding=4), #随机裁剪
transforms.RandomErasing(scale=(0.04, 0.2), ratio=(0.5, 2)) #随机遮挡
])
# 下载CIFAR10训练集
trainset = torchvision.datasets.CIFAR10(root='./data', train=True, download=True, transform=transform)
# 加载训练集,训练集需要打乱,每次训练4个样本
# trainloader = torch.utils.data.DataLoader(trainset, batch_size=4, shuffle=True, num_workers=0)
# trainloader = torch.utils.data.DataLoader(trainset, batch_size=1, shuffle=True, num_workers=0)
# train_batch_size = 10
# 每次给模型扔128张图片
# train_batch_size = 128
train_batch_size = 512
# val_batch_size = 50
val_batch_size = 512
# test_batch_size = 30
test_batch_size = 512
# train_batch_size = 100
# 假设我们要将数据集划分为90%训练集和10%验证集
split = int(len(trainset) * 0.9)
# 把随机种子固定下来,这样多次训练,就不会变化,从而避免验证集精度虚高
torch.manual_seed(42)
np.random.seed(42)
random.seed(42)
# 使用random_split函数随机划分数据集,它是torch封装的一个工具
trainset, valset = random_split(trainset, [split, len(trainset) - split])
# 创建数据加载器
# train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2)
# val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=2)
# 打乱数据
trainloader = torch.utils.data.DataLoader(trainset, batch_size=train_batch_size, shuffle=True, num_workers=0)
valloader = torch.utils.data.DataLoader(valset, batch_size=val_batch_size, shuffle= False, num_workers=0)
# trainloader = torch.utils.data.DataLoader(trainset, batch_size=1, shuffle=True, num_workers=2)
# trainloader = torch.utils.data.DataLoader(trainset, batch_size=9, shuffle=True, num_workers=0)
# 下载CIFAR10测试集,所以train这个参数这里要设置为False
testset = torchvision.datasets.CIFAR10(root='./data', train=False, download=True, transform=transform)
# 加载测试集,测试集不需要打乱
# testloader = torch.utils.data.DataLoader(testset, batch_size=4, shuffle=False, num_workers=0)
# 测试的时候可以不用打乱数据
# testloader = torch.utils.data.DataLoader(testset, batch_size=1, shuffle=False, num_workers=0)
# testloader = torch.utils.data.DataLoader(testset, batch_size=10, shuffle=False, num_workers=0)
testloader = torch.utils.data.DataLoader(testset, batch_size=test_batch_size, shuffle=False, num_workers=0)
# testloader = torch.utils.data.DataLoader(testset, batch_size=9, shuffle=False, num_workers=0)
# 定义CIFAR10数据集的十分类类别
classes = ('plane', 'car', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck')
import numpy as np
# import matplotlib.pyplot as plt
# 定义一个展示图片的函数
def imshow(img):
img = img / 2 + 0.5
npimg = img.numpy()
# plt.imshow(np.transpose(npimg, (1, 2, 0)))
# plt.show()
#因为这块只是展示图片,没太多必要,所以先注释掉
# 从数据迭代器中读取一张图片
#dataiter = iter(trainloader)
#images, labels = next(dataiter)
# # 展开图片
#imshow(torchvision.utils.make_grid(images))
# #打印标签
#print(''.join('%15s' % classes[labels[j]] for j in range(4)))
import torch.nn as nn
import torch.nn.functional as F
class Net(nn.Module):
def __init__(self):
super(Net,self).__init__()
#定义两个卷积层
# self.conv1 = nn.Conv2d(3,6,5)
# self.conv2 = nn.Conv2d(6,16,5)
# 第一个卷积层,输入通道数为3(因为是RGB图片,有3个颜色通道),输出通道数为6,这个是可以随意设置,卷积核大小为3*3
# self.conv1 = nn.Conv2d(3,6,3)
# 如果卷积核是3*3,那么给周围填充1,刚好尺寸不变
self.conv1 = nn.Conv2d(3,64,3,padding=1)
# 第二个卷积层,输入通道数为6,因为上次卷积之后输出是6个特征图,输出通道数为16,这个是可以随意设置,卷积核大小同样为3*3
self.conv2 = nn.Conv2d(64,64,3,padding=1)
#新增加一个卷积层,因为上一个卷积层输出是16个特征图,所以输入通道数为16,输出通道数为16,这个是可以随意设置,卷积核大小同样为3*3
self.conv3 = nn.Conv2d(64,128,3,padding=1)
self.conv4 = nn.Conv2d(128,128,3,padding=1)
self.conv5 = nn.Conv2d(128,256,3,padding=1)
self.conv6 = nn.Conv2d(256,256,3,padding=1)
self.conv7 = nn.Conv2d(256,256,3,padding=1)
self.conv8 = nn.Conv2d(256,512,3,padding=1)
self.conv9 = nn.Conv2d(512,512,3,padding=1)
self.conv10 = nn.Conv2d(512,512,3,padding=1)
# self.conv11 = nn.Conv2d(512,512,3,padding=1)
# self.conv12 = nn.Conv2d(512,512,3,padding=1)
# self.conv13 = nn.Conv2d(512,512,3,padding=1)
# 做了批归一化,能有效避免梯度消失和梯度爆炸
self.bn64 = nn.BatchNorm2d(64)
self.bn128 = nn.BatchNorm2d(128)
self.bn256 = nn.BatchNorm2d(256)
self.bn8 = nn.BatchNorm2d(512)
self.bn9 = nn.BatchNorm2d(512)
self.bn10 = nn.BatchNorm2d(512)
# self.bn11 = nn.BatchNorm2d(512)
# self.bn12 = nn.BatchNorm2d(512)
# self.bn13 = nn.BatchNorm2d(512)
# self.conv3 = nn.Conv2d(16,9,3)
#再增加一个卷积层
# self.conv4 = nn.Conv2d(16,6,1)
#定义池化层,池化核大小为2*2
self.pool = nn.MaxPool2d(2,2)
#定义三个全连接层
# self.fc1 = nn.Linear(16*5*5,120)
#每次输入4个样本,每个样本有3个通道,所以输入通道数为4*3*3*3
# self.fc1 = nn.Linear(16*3*3,120)
# self.fc1 = nn.Linear(16*4*3*3,120)
# self.fc1 = nn.Linear(16*4,120)
# self.fc1 = nn.Linear(16*16*2*169,120)
# self.fc1 = nn.Linear(73728,120)
# self.fc1 = nn.Linear(256*22*22,120)
# self.fc1 = nn.Linear(256*4*4,120)
# self.fc1 = nn.Linear(512*4*4,120)
# self.fc1 = nn.Linear(512*4*4,800)
self.fc1 = nn.Linear(512*2*2,800)
# self.fc1 = nn.Linear(256*5*5,120)
# self.fc1 = nn.Linear(144*16*16*2,120)
# self.fc1 = nn.Linear(6,120)
# self.fc1 = nn.Linear(4*4*4*3*3,120)
# self.fc2 = nn.Linear(120,84)
self.fc2 = nn.Linear(800,84)
# self.fc4 = nn.Linear(84,84)
# self.fc5 = nn.Linear(84,84)
# self.fc2 = nn.Linear(120,84)
self.fc3 = nn.Linear(84,10)
# 增加drop-out层,丢弃概率
# self.dropout = nn.Dropout(0.5)
# 定义前向传播,模型的关键还在于前向传播
def forward(self,x):
# x = self.pool(F.relu(self.conv1(x)))
x = F.relu(self.bn64(self.conv1(x)))
x = self.pool(F.relu(self.bn64(self.conv2(x))))
# x = self.pool(F.relu(self.conv2(x)))
# x = F.relu(self.conv2(x))
# x = self.pool(F.relu(self.conv3(x)))
# x = F.relu(self.conv3(x))
x = F.relu(self.bn128(self.conv3(x)))
# x = F.relu(self.conv4(x))
# x = self.pool(F.relu(self.conv4(x)))
x = self.pool(F.relu(self.bn128(self.conv4(x))))
# x = F.relu(self.conv5(x))
x = F.relu(self.bn256(self.conv5(x)))
# x = self.pool(F.relu(self.conv5(x)))
x = F.relu(self.bn256(self.conv6(x)))
# x = F.relu(self.conv7(x))
# x = self.pool(F.relu(self.conv7(x)))
x = self.pool(F.relu(self.bn256(self.conv7(x))))
# x = self.pool(F.relu(self.bn8(self.conv8(x))))
x = F.relu(self.bn8(self.conv8(x)))
# x = F.relu(self.bn9(self.conv9(x)))
x = self.pool(F.relu(self.bn9(self.conv9(x))))
x = F.relu(self.bn10(self.conv10(x)))
# x = self.pool(F.relu(self.bn10(self.conv10(x))))
# x = F.relu(self.bn11(self.conv11(x)))
# x = F.relu(self.bn12(self.conv12(x)))
# x = F.relu(self.bn13(self.conv13(x)))
#变换x的形状以适配全连接的输入,因为全连接层输入必须是二维的,所以需要将x的形状变成二维的,因为图片是32*32*3,需要将三维变量拉长
# x = x.view(-1,16*5*5)
# x = x.view(-1,16*3*3)
# x = x.view(-1,16*4*3*3)
# x = x.view(-1,16*4)
# x = x.view(-1,16*16*2*169)
# x = x.view(-1,73728)
# x = x.view(-1,256*22*22)
# x = x.view(-1,256*4*4)
# x = x.view(-1,512*4*4)
x = x.view(-1,512*2*2)
# x = x.view(-1,256*5*5)
# x = x.view(-1,144*16*16*2)
# x = x.view(-1,6)
# x = x.view(-1,4*4*4*3*3)
# x = self.dropout(x)
x = F.relu(self.fc1(x))
# x = self.dropout(x)
x = F.relu(self.fc2(x))
# x = self.dropout(x)
# x = F.tanh(self.fc2(x))
# x = F.sigmoid(self.fc2(x))
# x = F.relu(self.fc4(x))
# x = F.relu(self.fc5(x))
x = self.fc3(x)
# print(x.shape)
return x
net = Net().to(device)
# 直接加载训练好的模型,就不需要从头开始训练了。如果不加载,一上来损失就是2.290,很高。如果加载了模型,一上来损失只有0.127,效果太明显了,这就是微调的作用
# net.load_state_dict(torch.load('./cifar_net56.pth'))
# num_classes,代表最终分类的数量,因为CIFAR10有10个分类,所以这里要设置为10
# net = resnet18(pretrained=False, num_classes=10).to(device)
# net = resnet18(pretrained=True, num_classes=10).to(device)
# net = resnet18(pretrained=True)
# net = resnet34(pretrained=True)
# net = resnet50(pretrained=False)
# net = resnet101(pretrained=True)
# 冻结模型的所有层,除了最后的全连接层
# for param in net.parameters():
# param.requires_grad = False
#
# last_layer = net.fc
# # 替换最后一个全连接层
# num_classes = 10 # CIFAR10的数据类数为10
# new_last_layer = torch.nn.Linear(last_layer.in_features, num_classes)
# net.fc = new_last_layer
# num_ftrs = net.fc.in_features
# net.fc = nn.Linear(num_ftrs, 10)
# 检查是否有可用的GPU,如果有多个GPU,则使用 DataParallel
if torch.cuda.device_count() > 1:
print(f"Let's use {torch.cuda.device_count()} GPUs")
net = nn.DataParallel(net)
net.to(device)
print(net)
import torch.optim as optim
# 定义损失函数,CrossEntropyLoss()是交叉熵损失函数
criterion = nn.CrossEntropyLoss()
#原本是0。001,改为0.0001
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9)
optimizer = optim.Adam(net.parameters(),lr = 0.0001)
# 只更新全连接层的参数,把卷积层的参数冻结,不更新
# optimizer = optim.SGD(net.fc.parameters(),lr = 0.0001, momentum = 0.9)
# optimizer = optim.SGD(net.fc.parameters(),lr = 0.0002, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.0002, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9,weight_decay=0.001)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9,weight_decay=0.01)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9,weight_decay=0.005)
# optimizer = optim.SGD(net.parameters(),lr = 0.0005, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.00001, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.001, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.3, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.5, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 1, momentum = 0.9)
# 为了避免过拟合,考虑加入正则化,1e-4
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9, weight_decay=0.003)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9, weight_decay=0.005)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9, weight_decay=0.01)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9, weight_decay=0.001)
# optimizer = optim.SGD(net.parameters(),lr = 0.0001, momentum = 0.9, weight_decay=0.0001)
# optimizer = optim.SGD(net.parameters(),lr = 0.001, momentum = 0.9)
# optimizer = optim.SGD(net.parameters(),lr = 0.01, momentum = 0.9)
# 使用ADAM梯度下降方法
# optimizer = optim.Adam(net.parameters(),lr = 0.01)
# min_loss = 0.3
# min_loss = 0.3
#
midModelPath = './cifar_net103.pth'
finallPATH = './cifar_net104.pth'
max_val_score = 0
break_max_val = False
epoch_list = []
val_acc_list = []
# 每次验证的正确率
every_val_acc = ''
def train(epoch):
# net = Net().to(device)
# print(net)
# net.load_state_dict(torch.load('./cifar_net76.pth'))
# net.load_state_dict(torch.load('./cifar_net75.pth',map_location= 'cpu'))
min_loss = 0.3
for epoch in range(epoch):
running_loss = 0.0
# 该代码片段使用enumerate()函数遍历trainloader数据加载器,并为每个数据项提供一个索引。
# enumerate()函数从0开始计数,并将每个数据项和相应的索引存储在一个元组中,
# 然后将这些元组传递给循环中的变量i和data。这样可以在遍历数据时轻松地访问每个数据项的索引,
# 以便执行相应的操作。
for i,data in enumerate(trainloader,0):
inputs,labels = data
# print(inputs)
#将梯度清零,如果不清零,梯度会累加,导致梯度爆炸,或者梯度消失,导致训练效果不好
optimizer.zero_grad()
#得到输出张量
outputs = net(inputs.to(device))
# print(outputs.shape)
# print(labels.shape)
#计算损失值
loss = criterion(outputs,labels.to(device))
#进行反向传播
loss.backward()
#更新参数
optimizer.step()
#打印训练信息
running_loss += loss.item()
# if(i +1)% 2000 == 0:
# if(i +1)% 100 == 0:
if(i +1)% 10 == 0:
runTime = time.strftime("%H小时%M分%S秒", time.gmtime(time.time() - start_time))
tqdm.write(f"Runtime:{Fore.GREEN}{runTime}{Style.RESET_ALL}")
# print('[第%d轮, 训练%5d个样本] loss : %.3f' % (epoch +1, (i+1)*train_batch_size,running_loss /2000))
tqdm.write(
f"{Fore.YELLOW}{epoch+1}{Style.RESET_ALL}轮,训练{Fore.YELLOW}{(i+1)*train_batch_size}{Style.RESET_ALL}个样本, loss: {Fore.CYAN}{running_loss/2000}{Style.RESET_ALL}")
sys.stdout.flush() # 强制刷新输出缓冲区
# 以时分秒的形式打印下程序运行时长
# print('Runtime',time.strftime("%H:%M:%S",time.gmtime(time.time()-start_time)))
# print('Runtime',time.strftime("%H小时%M分%S秒",time.gmtime(time.time()-start_time)))
torch.save(net.state_dict(), midModelPath)
verify()
global break_max_val
if (break_max_val):
tqdm.write(
# f"{Back.LIGHTBLUE_EX} {Fore.LIGHTRED_EX}🔥 打破记录了!!!{Back.RESET}{Style.RESET_ALL}")
f"{Fore.LIGHTRED_EX}🔥 打破记录了!!!{Style.RESET_ALL}")
mytestTrainSet()
eval()
# if running_loss/2000 < min_loss:
# if running_loss/2000 < min_loss:
# print('打破记录')
# min_loss = running_loss/2000
# # PATH = './cifar_net70.pth'
# PATH = midModelPath
# torch.save(net.state_dict(),PATH)
# # 打印验证集精度
# verify()
# # 打印训练集精度
# testTrainSet()
# 刷新输出缓冲区,作用是确保输出信息立即显示在控制台上,不然在云服务器上半天显示不出来
sys.stdout.flush()
running_loss = 0.0
print("")
print('完成训练')
#
# PATH = './cifar_net71.pth'
PATH = finallPATH
# # # # #保存模型
torch.save(net.state_dict(),PATH)
eval()
def trainResNet(epoch):
net.load_state_dict(torch.load('./cifar_net101.pth',weights_only=True))
min_loss = 0.3
# 加载预训练的ResNet模型(如果需要从头开始训练,则可以去掉预训练权重)
# net = resnet18(pretrained=False, num_classes=10)
# print(net)
for epoch in range(epoch):
running_loss = 0.0
# 该代码片段使用enumerate()函数遍历trainloader数据加载器,并为每个数据项提供一个索引。
# enumerate()函数从0开始计数,并将每个数据项和相应的索引存储在一个元组中,
# 然后将这些元组传递给循环中的变量i和data。这样可以在遍历数据时轻松地访问每个数据项的索引,
# 以便执行相应的操作。
for i,data in enumerate(trainloader,0):
inputs,labels = data
#将梯度清零,如果不清零,梯度会累加,导致梯度爆炸,或者梯度消失,导致训练效果不好
optimizer.zero_grad()
#得到输出张量
outputs = net(inputs.to(device))
#计算损失值
loss = criterion(outputs,labels.to(device))
#进行反向传播
loss.backward()
#更新参数
optimizer.step()
#打印训练信息
running_loss += loss.item()
if(i +1)% 2000 == 0:
runTime = time.strftime("%H小时%M分%S秒", time.gmtime(time.time() - start_time))
tqdm.write(f"Runtime:{Fore.GREEN}{runTime}{Style.RESET_ALL}")
# print('[第%d轮, 训练%5d个样本] loss : %.3f' % (epoch +1, (i+1)*train_batch_size,running_loss /2000))
tqdm.write(
f"{Fore.YELLOW}{epoch + 1}{Style.RESET_ALL}轮,训练{Fore.YELLOW}{(i + 1) * train_batch_size}{Style.RESET_ALL}个样本, loss: {Fore.CYAN}{running_loss / 2000}{Style.RESET_ALL}")
# 以时分秒的形式打印下程序运行时长
# print('Runtime',time.strftime("%H:%M:%S",time.gmtime(time.time()-start_time)))
# print('Runtime',time.strftime("%H小时%M分%S秒",time.gmtime(time.time()-start_time)))
# if running_loss/2000 < min_loss:
# print('打破记录')
# min_loss = running_loss/2000
# PATH = midModelPath
# # PATH = './cifar_net67.pth'
# # #保存模型
# torch.save(net.state_dict(),PATH)
# verify()
torch.save(net.state_dict(), midModelPath)
verify()
global break_max_val
if (break_max_val):
tqdm.write(
# f"{Back.LIGHTBLUE_EX} {Fore.LIGHTRED_EX}🔥 打破记录了!!!{Back.RESET}{Style.RESET_ALL}")
f"{Fore.LIGHTRED_EX}🔥 打破记录了!!!{Style.RESET_ALL}")
mytestTrainSet()
eval()
# 刷新输出缓冲区,作用是确保输出信息立即显示在控制台上,不然在云服务器上半天显示不出来
sys.stdout.flush()
running_loss = 0.0
print("")
epoch_list.append(epoch + 1)
val_acc_list.append(every_val_acc)
if (epoch + 1) % 5 == 0:
draw(epoch_list, val_acc_list)
print('完成训练')
#
# PATH = './cifar_net68.pth'
PATH = finallPATH
# # # # #保存模型
torch.save(net.state_dict(),PATH)
eval()
#
#在整个测试集上测试模型的准确率,53%,比之前55%还降了两个百分点。加了一层,正确率变成了46%,妹的,打算训练十次,看看,训练了十次,正确率变成39%,奶奶个熊
#直接把学习率改成0.0001,训练了十次,损失变为1.163,降了很多。突然正确率又提升到了56%
#决定把卷积层又恢复成两层,看看效果。因为batch给了9,一下子就训练完了,loss值为1.455,正确率为48%,效果不佳,
#加层数反而效果不好,可能加的不够好吧,然后把训练十次,改为30次,试试,第12轮的时候,损失为1.087,感觉正确率一定会超过56%,再训练第30轮的时候,有一次损失才0.793
#最终的损失是0.854,有点回升了。最终正确率为63%,如果在最低的损失值时,保存模型,那么正确率应该会更高。
#发现卷积层和全连接层没有必然联系,所以去掉了一个全连接层,试试效果。第12轮的时候,损失为1.027,但是很可惜,没有保存模型。
# 打算训练100轮,试试效果,这就好比把卷子做50遍,效果可能不太好,那就做100遍,相信效果会好很多。迭代100次太慢了,第42抡,有一次损失是0.727,也不确定会不会降低到0.6以下。
# 回家路上没有关电脑,不然又要重新训练。电脑烫死了,在59轮时,有一次损失是0.642。感觉100轮下来,还真有可能降低到0.6以下。70论的时候,有一次损失是0.616.第82轮的时候,
# 有一次降到了0.581,可惜因为之前程序写的不对,没法保存那个模型。最终的损失是0.697.正确率为61%,好气人,竟然降了。然后在训练集上跑了下,正确率为78%。不是很高,也就是说
#给它开卷考试,满分100分,也只能得到78分,说明学的还是不够。
#准备采用VGG网络架构,第一层卷积层直接从6个卷积核提升到64个卷积核,第二层也是64个卷积核,第三层128个卷积核,第四层也是128个卷积核。准备训练10轮,看看效果
# 在第九轮的时候,直接损失降低到0.560,这可是之前从来没有过的好成绩,说明还是得卷积盒多一些才可以。但是因为程序没写好,被多次覆盖掉。第十轮有一次损失是0.482
# 最终损失是0.565.在训练集上的正确率是84%,显著提升,在测试集上的正确率是73%,很厉害了
# 现在直接改造成了10层卷积层,哈哈,按照VGG的前十层架构,进行训练,然后把池化层去掉了,因为池化层经过三轮之后,变成2*2大小了,用3*3的卷积核没法计算了
# 网络层数加深之后,明显计算太慢了。下班也训练不完,打算训练五轮试试,看看一个小时能不能训练完,发现层数太深了,根本就训练不动,可能是由于链式法则,乘的数太多了,
# 导致每次只往前走一丁点,所以,可以考虑把学习率提高。
# 还是用三层架构,训练到第13抡,损失为0.019.我的天呐,牛啊,训练到第16抡,有一次损失为0.015,牛逼。训练到第18轮,有一次损失为0.006,不断降低损失,刷新记录。
# 在第29轮的时候,有一次损失为0.001.太吊了吧。在测试集上的正确率是69%,在训练集上的正确率是99%,很棒,说明是过拟合了。
#准备加上正则化,看看效果。计划训练30轮,在服务器上训练。
# 加上正则化后,在测试集上正确率是68%,在训练集上正确率是97%,我的天呐,还是解决不了过拟合的问题。
# 打算把正则化提升点,试试效果,加入正则化之后,在第17轮的时候,损失降到0.012.在训练集上的正确率是99%,在测试集上的正确率是69%,并且测试了两个模型,
# 还包括一个损失为0.010的,测试集上正确率也是69%,说明还是过拟合了。决定再次把正则化的系数从0.001,改为0.01,看看效果。
# 修改成0.01之后,训练了20轮,用时15个小时,最终损失为0.761,降不下去,然后在训练集上正确率为81%,在测试集上正确率是67%,反而效果还差了,
# 那就又将正则化惩罚项系数改为0.005,看看效果。在第14轮的时候,损失降到0.208.到时候看看结果,如果结果不理想,就继续把系数往下降。
# 第15轮的时候降到了0.188,看来有机会低于0.1啊。
# 最终损失是0.303,在测试集上正确率是66%,在训练集上正确率是96%。
# 打算把正则化惩罚项系数改为0.003,看看效果,最低的损失是0.058,在测试集上的正确率是66%,在训练集上的正确率是96%。过拟合问题太难搞了
# 加入drop-out,试试效果,给三个卷积层都加入了dropout
# dropout比例设置为0.5,损失到1.8附近,就不往下降了。调到0.1试试
# 不知道咋回事,加入dropout效果并不好,训练了三十轮,损失降不下去,也不知道是不是因为学习率为0.001的缘故,
# 现在把dropout去掉,加入数据增加,平移,反转,遮盖,来试试效果。
# 不知道咋回事,训练了30抡,损失是0.542,也太差了吧。在训练集上精度是80%,在测试集上精度是75%,竟然不过拟合了,不过损失为什么降不下去
# 打算再加一层卷积层,一共四个卷积层,试试,最终损失0.482,在训练集上的精度是83%,在测试集上的精度是79%,哇塞,有提升,终于快接近80%了。
# 打算再加一层,加到五个卷积层,这样,应该就可以上80%了
# 五层卷积层,最终损失为0.470,在训练集上的精度是84%,在测试集上的精度是80%,哇偶,终于上80%了
# 五层卷积层,最佳损失为0.422,在训练集上的精度是83%,在测试集上的精度是80%
# 打算第二层卷积层后面加个池化层,在五层卷积层后面也加个池化层,看看效果。
# 加入池化层之后,第六轮22000样本,只用了1小时15分钟,没加池化层之前用了1小时17分钟。我以为训练速度会加快的,并没有
# 训练完30轮,最终只用了7小时4分钟,不加池化层,用了6小时58分钟,我咋记得得二十几个小时呢。训练的模型只需要4.04M,之前都是40M-58M这样
# 加入两个池化层之后,最终损失是0.512,在训练集上的精度是84%,在测试集上的精度是80%
# 加入两个池化层之后,最佳损失是0.423,在训练集上的精度是84%,在测试集上的精度是80%
# 看来加入池化层,对于提高精度作用不大,那就继续增加网络层数吧。
# 再加上六七两个卷积层,但是加上这两层之后,训练不动了,损失不往下降,试着把学习率从0.0001改为0.00001试试
# 好像还是不行,那就把学习率提高为0.001,试试,后来发现能训练得动,比较慢而已
# 再加两个卷积层之后,最终损失是0.461,在训练集上的精度是84%,在测试集上的精度是80%。30轮训练用时12个小时,之前也就7个小时左右,因为网络更复杂了
# 也不知道是不是因为30轮,还不够,打算再训练20轮,看看效果,看看损失能不能降到0.4以下
# 在进行到第43轮的时候,损失为0.334,在训练集上的精度是87%,在测试集上的精度是82%,哇塞,果然被自己猜对了,我就是加了两层网络,怎么可能精度没有变化呢。
# 最佳损失为0.298,在训练集上的精度是89%,在测试集上的精度是83%,哇偶,开心,果然增加模型复杂度,可以提升精度
# 最终损失为0.371,在训练集上的精度是88%,在测试集上的精度82%
# 决定再加上三层卷积层,3个512特征图,然后训练轮数从50轮提升到70轮,看看效果。并将学习率从0.0001提高到0.0005,避免训练不动
# 将学习率提升到0.5,训练了一个晚上,损失还是3.7,根本不往下降。
# 问了下ai,这种情况应该是属于梯度消失了,可以采用批归一化,试试
# 哇塞,果然是梯度消失了,给新加的三个卷积层加入批归一化之后,采用0.0001的学习率,明显可以训练得动了。
# 训练了70轮,用了四五天,第一次花这么长时间用来训练,耗时为什么这么长,主要是因为模型更复杂了,而且batch_size给的太小了
# 最佳损失0.404,在训练集上的精度是85%,在测试集上的精度是80%
# 最终损失0.504,在训练集上的精度是84%,在测试集上的精度是79%
# 加了三层卷积层之后,竟然效果还没有之前好,有点欠拟合。但是我发现好像增加训练轮数,也训练不动了。
# 弄上阿里云的gpu之后,再训练30轮试试,毕竟用上了GPU,一轮也就6分钟,30轮,也就3小时左右,
# 在GPU上训练可太快了,而且是英伟达A10的GPU,简直狂飙啊。一轮只用了3分钟,比昨天自己用的那个GPU快多了
# 而且因为自己的batch_size设置的是1,而且num_workers设置的是0,不然会更快,我的天!
# 不知道咋搞的,设置了30轮,但是只训练了15轮
# 最佳损失是0.341,在训练集上的精度是88%,在测试集上的精度是82%
# 最终损失是0.412,在训练集上的精度是88%,在测试集上的精度是81%
# 又训练了十轮
# 最佳损失是0.270,在训练集上的精度是%,在测试集上的精度是81%
# 最终损失是0.309,在训练集上的精度是%,在测试集上的精度是82%
# 还有一点,我觉得最后的全连接,从8192直接到120,是不是120给的太小了,可以给到1000?
# 发现这里面没有padding层,可以考虑加个padding层,看看效果。
# 给每个卷积层增加了padding=1,这样卷积过程中尺寸不变,然后弄了三个池化层,最终的特征图尺寸是4*4*512,
# 增加padding了之后,扫描用的时间可能会多一些。
# 效果竟然出奇的好,在24轮的时候,损失为0.254,在训练集上的精度是90%,在测试集上的精度是85%
# 最终损失0.138,在训练集上的精度是95%,在测试集上的精度是87%
# 最佳损失为0.111,在训练集上的精度是95%,在测试集上的精度是87%
#打算把第二个全连接层参数从120,换成800,然后把num_workers从0换成2,试试效果
# 在第44轮的时候,损失为0.113,在训练集上的精度是95%,在测试集上的精度是88%
# 我的天,太炸裂了,又提升了一个百分点,而且这次因为不急着回去,可以训练70轮,我觉得最终还会涨一点点
# 最佳损失0.067,在训练集上的精度是%,在测试集上的精度是87%
# 最终损失0.089,在训练集上的精度是97%,在测试集上的精度是89%,这个就训练的很充分了
# 我的天,也太牛逼了,果然又提升了一个百分点,哈哈,这真是可喜可贺啊
# 然后决定再加上三层的512特征图,这样就是完整的VGG16了,看看还能不能提高一两个百分点。冲90%有希望了呀。
# 训练了46轮,损失是0.154,因为要下班了,所以就暂停,明天继续训练,然后明天再训练30轮,我觉得会破记录的。
# 最佳损失0.087,在训练集上的精度是96%,在测试集上的精度是87%
# 最终损失0.109,在训练集上的精度是%,在测试集上的精度是87%
# 我觉得可能是训练不够充分吧,不然不可能还不如从前了,要么就是过拟合了,现在有两种办法,第一,增加20轮训练,第二,增加dropout。
# 又训练了20轮
# 最佳损失0.063,在训练集上的精度是%,在测试集上的精度是88%
# 最终损失XX,在训练集上的精度是96%,在测试集上的精度是88%
# 打算增加dropout,看看效果,之前可能dropout加的不对
# 在第一个全连接层,和第二个全连接层,增加dropout,比例为0.2
# 训练40轮,试试,之前那个GPU-DSW打不开了,重新弄了个,下载数据集好快啊
# 后面可以考虑把batch_size调大一些,这样训练更快
# 训练了40轮,最佳损失为0.212,在训练集上的精度是90%,在测试集上的精度是84%
# 最终损失0.260,在训练集上的精度是89%,在测试集上的精度是83%
# 好像泛化能力比之前能好一点,从8-9个点,减少到6个点,
# 因为今天周六,现在已经晚上八点,训练不了多久,再来十轮,把batch_size设置为10
# 把batch_size设置为10,现在训练速度更快了,太厉害了
# 既然训练速度这么快,那就干脆再训练20轮,反正20轮,20分钟就完了,原本每轮得4分钟,需要80分钟,
# 哈哈,自己太笨了,不然早点把batch_size调大了。
# 再经过了20轮,相当于总共训练了70轮
# 最佳损失是0.109,在训练集上的精度是96%,在测试集上的精度是88%
# 最终损失是0.115,在训练集上的精度是%,在测试集上的精度是88%
# 再训练20轮,相当于训练了90轮
# 最佳损失是0.092,在训练集上的精度是96%,在测试集上的精度是88%
# 最终损失是0.096,在训练集上的精度是%,在测试集上的精度是%
# 明天打算把dropout丢弃比例设置为0.5,训练100轮试试看
# 训练了100轮,只用了1小时14分钟,果然batch_size作用很大啊
# 但是大的batch_size可能需要更多的轮数。因为反向传播的次数相对比较少
# 最终损失0.159,最佳损失0.149。可能是因为drop-out0.5,所以损失有点大.
# 打算再训练50轮,看看效果
# 最佳损失0.088,在训练集上的精度是97%,在测试集上的精度是87%
# 我晕死,我感觉这个drop-out没有发挥作用啊。
# 那不要drop-out了,继续搞正则化试试,反正就是炼丹嘛,哈哈
# 先把正则化惩罚项弄个0.001试试。
# 直接训练150轮,试试
# 最佳损失为0.091,在训练集上的精度是96%,在测试集上的精度是87%
# 真是一个让人失望的结果啊,明天把正则化系数提升下,训练150轮试试
# 将正则化系数设置为0.01,训练150轮
# 训练了150轮,最终损失为0.351,在训练集上的精度是89%,在测试集上的精度是84%
# 再训练上100轮,看看效果,损失不太好往下降。
# 又训练了50轮,损失还是0.345,降不下去了。
# 打算把正则化惩罚项系数调整为0.005,训练200轮试试
# 最终损失为0.200,在训练集上的精度是93%,在测试集上的精度是85%
# 最佳损失为0.176,在训练集上的精度是94%,在测试集上的精度是86%
# 效果不明显啊,打算再训练100轮试试,感觉还是没有解决过拟合的问题
# 又训练了40轮,损失为0.167,在训练集上的精度是%,在测试集上的精度是86%
# 训练到100轮,最终损失为0.173,没有太大变化,在测试集上的精度是85%
# 直接把正则化惩罚项系数去掉,然后直接用一个预训练的ResNet18来训练下,看看效果
# 这一次pretrained=False,是一个空的模型,需要自己训练,下次可以直接pretrained=True,用一下它训练好的参数,试试
# 训练了100轮,损失降到0.60左右,打算再训练100轮试试
# 又训练了150轮,共250轮,最终损失为0.355,那就再训练150轮。
# 为什么要训练那么多轮数,是因为真的Resnet18比自己之前的网络要更复杂。
# 共训练了400轮,损失为0.233,在训练集上的精度是91%,在测试集上的精度是81%
# 再训练100轮吧。
# 这个ResNet18收敛速度太慢了,
# 共训练了500轮,最佳损失为0.193,模型保存文件为64
# 打算继续训练下去,但是现在没时间了,只能明天,打算试一下预训练模型,看看效果
# 把卷积层的参数冻结了,只训练全连接层的参数,看看效果
# 共训练了700轮,最终损失为0.189,在测试集上的精度是81%
# 最佳损失为0.167,在训练集上的精度是%,在测试集上的精度是82%
# 原来这200轮训练的只是全连接层的参数,难怪损失降不下去,重新训练200轮吧。
# 第648轮的时候,损失为0.145,在训练集上的精度是94%,在测试集上的精度是81%
# 严重过拟合,干脆先不用ResNet了,先用自己搭建的VGG试试,给每层加上批归一化,说不定可以上90%
# 第84轮,损失为0.111,在训练集上的精度是95%,在测试集上的精度是87%
# 第116轮,损失为0.076,在训练集上的精度是97%,在测试集上的精度是88%
# 第176轮,损失为0.049,在训练集上的精度是98%,在测试集上的精度是88%
# 最终损失0.050,在测试集上的精度是89%
# 最佳损失0.044,在训练集上的精度是98%,在测试集上的精度是89%
# 又训练了60轮,最佳损失为0.031,最终损失为0.038,在测试集上的精度都是89%
# 又训练了40轮,最佳损失为0.029,在训练集上的精度是98%,在测试集上的精度是90%,明天看看能不能把损失降到0.01以下
# 我的天,上了90%,对得起自己熬夜到了23:26,哈哈。
# 再训练100轮试试,看看能降到0.01以下不。
# 第54轮,损失为0.024,在训练集上的精度是99%,在测试集上的精度是90%,稳稳妥妥的上了90%,看来批归一化效果还是很明显的,之前一直上不来
# 最佳损失0.021,不训练了,换成Adam试试
# 明天可以考虑把SGD换成Adam试试,然后也可以在程序中加入在测试集上的精度,随时打印输出
# 还有一个AdamW,是Adam的变种,也可以试试
# 换成Adam,然后加入了验证集,训练200轮试试。
# 带上验证集还挺好的,可以损失监督模型在验证集上的精度
# 训练了90轮,损失为0.096,在验证集上的精度是88%
# 明天继续训练,再来200轮,看看会不会上91%
# 我明白验证集昨天好好的,但是今天突然就很高,就是因为训练集和验证集是随便划分的,今天的验证集中的数据,昨天是出现在训练集里的,所以拟合程度会更高一些。
# 第261轮,损失为0.042,在训练集上的精度是98%,在测试集上的精度是90%
# 第300轮,损失为0.047,在测试集上的精度是90%
# 第414轮,损失为0.035,在训练集上的精度是98%,在测试集上的精度是90%
# 第459轮,损失为0.034,在测试集上的精度是90%,损失好像也降不下去了。
# 自从换成彩色的日志之后,训练集的精度小于验证集的精度,这个不合理,可能还是程序写的有问题。
# 训练到后面,训练集的精度就高于验证集和测试集的了
# 把网络结构搞简单一点,说不定泛化能力更强呢。去掉后面三个512的卷积层,试试效果
# 在第123轮时,损失为0.075,在验证集上的精度是90.8%,在训练集上的精度是97.7%,在测试集上的精度是89.54%
# 在第154轮时,损失为0.064,在验证集上的精度是90.94%,在训练集上的精度是97.97%,在测试集上的精度是90.12%
# 在第178轮时,损失为0.060,在验证集上的精度是91.14%,在训练集上的精度是98.24%,在测试集上的精度是90.21%
# 使用预训练ResNet18,加入了验证集,以及验证集精度曲线图
# 训练到82轮时,损失是为0.285,在验证集上的精度是85.02%,在训练集上的精度是90.92%,在测试集上的精度是84.22%
# 训练到106轮时,损失为0.2314,在验证集上的精度是85.28%,在训练集上的精度是91.79%,在测试集上的精度是83.95%
# 直接改用ResNet34试下,因为我感觉ResNet18泛化能力挺强的,但是有点欠拟合,因此要选择更复杂一点的网络结构
# 使用ResNet50训练
# 训练到37轮时,损失为0.3688,在验证集上的精度是84.96%,在训练集上的精度是88.33%,在测试集上的精度是84.13%
# 训练到69轮时,损失为0.265,在验证集上的精度是87.18%,在训练集上的精度是89.01%,在测试集上的精度是84.04%
# 训练到101轮时,损失为0.198,在验证集上的精度是87.44%,在训练集上的精度是93.75%,在测试集上的精度是86.28%
# 训练到119轮时,损失为0.174,在验证集上的精度是87.5%,在训练集上的精度是94.51%,在测试集上的精度是86.54%
# 训练到134轮时,损失为0.1638,在验证集上的精度是87.68%,在训练集上的精度是94.88%,在测试集上的精度是86.00%
# 训练到143轮时,损失为0.1514,在验证集上的精度是87.9%,在训练集上的精度是95.25%,在测试集上的精度是86.83%
# 训练到156轮时,损失为0.1493,在验证集上的精度是87.96%,在训练集上的精度是95.41%,在测试集上的精度是86.41%
# 再继续训练了109轮,损失降为0.104,不过在验证集上的精度是87.4%,不再往下降了
# 我发现用预训练的效果反而不好,那我就直接只用ResNet50网络结构,而不用它的预训练参数
# 第120轮时,损失为0.517,在验证集上的精度是80.16%,在训练集上的精度是82.43%,在测试集上的精度是78.76%
# 第200轮时,损失为0.420,在验证集上的精度是81.84%,在训练集上的精度是86.39%,在测试集上的精度是80.4%
# 使用ResNet101预训练模型,并且冻结前面的参数,只更新全连接层
# 训练到100多轮,其中在验证集上的精度达到46%,基本上就上不去了。
# 二供上云弄了云服务器,有英伟达V100的显卡,训练了2小时35分钟,第174轮的时候,在验证集上89.9%,训练集上98.49%,在测试集上88.08%
# 后面还可以加上ResNet,据说ResNet在ImageNet比赛的时候,错误率只有3%,好恐怖
def modelInfo():
params_num = sum(p.numel() for p in net.parameters());
print("当前模型共有%d个参数",params_num)
def verify():
PATH = midModelPath
net.load_state_dict(torch.load(PATH, map_location=device,weights_only=True))
correct = 0
total = 0
# 验证的时候,不需要记录梯度,加快速度
with torch.no_grad():
for data in valloader:
images, labels = data
outputs = net(images.to(device))
# _, predicted = torch.max(outputs.data.to('cpu'), 1)
_, predicted = torch.max(outputs.data.to('cpu'), 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
# print('验证集正确率:%d %%' % (100 * correct / total))
tqdm.write(
f"验证集正确率: {Fore.BLUE}{100 * correct / total}{Style.RESET_ALL}%")
global max_val_score
global break_max_val
global every_val_acc
every_val_acc = np.mean(correct / total)
if(correct/total > max_val_score):
max_val_score = correct/total
break_max_val = True
else:
break_max_val = False
def mytestTrainSet():
PATH = midModelPath
net.load_state_dict(torch.load(PATH, map_location=device,weights_only=True))
correct = 0
total = 0
# 验证的时候,不需要记录梯度,加快速度
with torch.no_grad():
for data in trainloader:
images, labels = data
outputs = net(images.to(device))
_, predicted = torch.max(outputs.data.to(device), 1)
total += labels.size(0)
correct += (predicted == labels.to(device)).sum().item()
tqdm.write(
f"训练集正确率: {Fore.BLUE}{round(100 * correct / total,2)}{Style.RESET_ALL}%")
def eval():
# net = Net().to(device)
# print(net)
# net = Net()
# # #加载模型
PATH = midModelPath
# PATH = finallPATH
net.load_state_dict(torch.load(PATH,map_location=device,weights_only=True))
# net.load_state_dict(torch.load(PATH))
# for param in net.parameters():
# print(param)
correct = 0
total = 0
# 测试的时候,不需要记录梯度,加快速度
with torch.no_grad():
for data in testloader:
# for data in trainloader:
images,labels = data
outputs = net(images.to(device))
_,predicted = torch.max(outputs.data.to(device),1)
total += labels.size(0)
correct += (predicted == labels.to(device)).sum().item()
# print('正确率:%d %%'% (100*correct /total))
tqdm.write(
f"测试集正确率: {Fore.BLUE}{round(100 * correct / total,2)}{Style.RESET_ALL}%")
def draw(epoch_list,val_acc_list):
plt.plot(epoch_list, val_acc_list)
plt.xlabel("epoch")
plt.ylabel("val_acc")
plt.show()
if __name__ == '__main__':
# eval();
# verify()
train(200);
# trainResNet(300);
# modelInfo();
+12
View File
@@ -0,0 +1,12 @@
[
"丁香羊肉",
"丁香鸭",
"丁香鸭子",
"三丝豆腐",
"鱼香肉丝",
"宫保鸡丁",
"麻婆豆腐",
"红烧肉",
"糖醋排骨",
"北京烤鸭"
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+122 -23
View File
@@ -317,8 +317,89 @@ class FAISSIndexBuilder:
print(f"特征向量已保存到: {embeddings_path}")
def build_complete_index(self, train_dir: str, output_dir: str,
batch_size: int = 32, index_type: str = 'flat'):
def select_representative_samples(
self,
embeddings: np.ndarray,
image_paths: List[str],
labels: List[int],
max_per_class: int = 30) -> Tuple[np.ndarray, List[str], List[int]]:
"""
使用 K-Means 为每个类别筛选代表性样本,消除样本数量不均衡带来的密度偏差。
原理:
- 样本数 <= max_per_class:全部保留
- 样本数 > max_per_class:对该类向量做 K-Means(k=max_per_class)
然后为每个聚类中心找最近的真实图片,只保留这些代表性样本
Args:
embeddings : 全量嵌入向量数组 (N, dim)
image_paths : 图片路径列表
labels : 标签索引列表
max_per_class: 每类最多保留的样本数(默认 30)
Returns:
Tuple[np.ndarray, List[str], List[int]]: 筛选后的 (embeddings, paths, labels)
"""
print(f"\n{'='*60}")
print(f"K-Means 代表性样本筛选 (max_per_class={max_per_class})")
print(f"{'='*60}")
embeddings_array = embeddings if isinstance(embeddings, np.ndarray) else np.array(embeddings)
labels_array = np.array(labels)
selected_global_indices: List[int] = []
for class_idx in np.unique(labels_array):
class_name = self.idx_to_class[int(class_idx)]
global_indices = np.where(labels_array == class_idx)[0]
count = len(global_indices)
if count <= max_per_class:
# 样本数不超过上限,全部保留
selected_global_indices.extend(global_indices.tolist())
print(f" [{class_name}]: {count} 张, 全部保留")
else:
# 样本数超过上限 -> K-Means 聚类后选代表
class_embs = embeddings_array[global_indices].astype(np.float32)
dim = class_embs.shape[1]
k = max_per_class
# 1. 用 FAISS 内置 K-Means 聚类(无需 sklearn
kmeans = faiss.Kmeans(dim, k, niter=30, verbose=False)
kmeans.train(class_embs)
centroids = kmeans.centroids # shape: (k, dim)
# 2. 为每个幽灵中心,找离它最近的真实图片(幽灵变真身)
temp_index = faiss.IndexFlatL2(dim)
temp_index.add(class_embs)
_, nearest_local = temp_index.search(centroids, 1) # (k, 1)
nearest_local = nearest_local[:, 0].tolist()
# 3. 去重(极少情况下两个中心可能最近邻相同)
unique_local = list(set(nearest_local))
# 4. 映射回全局索引
selected_global = global_indices[unique_local].tolist()
selected_global_indices.extend(selected_global)
print(f" [{class_name}]: {count} 张 -> K-Means 筛选为 {len(selected_global)}")
# 按索引排序,确保顺序稳定
selected_global_indices = sorted(selected_global_indices)
filtered_embeddings = embeddings_array[selected_global_indices]
filtered_paths = [image_paths[i] for i in selected_global_indices]
filtered_labels = [labels[i] for i in selected_global_indices]
total_before = len(image_paths)
total_after = len(filtered_paths)
print(f"\nK-Means 筛选完成: {total_before} -> {total_after} 个向量"
f"(减少了 {total_before - total_after} 个)")
print(f"{'='*60}\n")
return filtered_embeddings, filtered_paths, filtered_labels
def build_complete_index(self, train_dir: str, output_dir: str,
batch_size: int = 32, index_type: str = 'flat',
max_per_class: int = 30):
"""
完整的索引构建流程
@@ -329,32 +410,38 @@ class FAISSIndexBuilder:
index_type: 索引类型
"""
print("=" * 60)
print("始构建FAISS索引")
print("\u5f00始构建 FAISS 索引")
print("=" * 60)
# 1. 扫描训练数据
# 1. 扫描训练数据(全量,不做任何过滤)
image_paths, class_names, labels = self.scan_training_data(train_dir)
# 2. 提取全量特征向量(K-Means 聚类需要看到所有向量)
embeddings = self.extract_features_batch(image_paths, batch_size)
# 3. K-Means 代表性样本筛选(每类最多保留 max_per_class 个向量)
embeddings, image_paths, labels = self.select_representative_samples(
embeddings, image_paths, labels, max_per_class
)
self.image_paths = image_paths
self.labels = labels
# 2. 提取特征向量
embeddings = self.extract_features_batch(image_paths, batch_size)
self.embeddings = embeddings
# 3. 构建FAISS索引
# 4. 构建 FAISS 索引(使用筛选后的向量)
index = self.build_faiss_index(embeddings, index_type)
# 4. 保存索引和元数据
# 5. 保存索引和元数据
self.save_index_and_metadata(index, output_dir)
print("=" * 60)
print("FAISS索引构建完成!")
print(f"输出目录: {output_dir}")
print(f"总图片数: {len(image_paths)}")
print(f"类别数: {len(self.class_names)}")
print(f"特征维度: {self.embedding_dim}")
print("FAISS 索引构建完成!")
print(f"输出目录 : {output_dir}")
print(f"入库向量数 : {len(image_paths)}K-Means 筛选后)")
print(f"类别数 : {len(self.class_names)}")
print(f"每类上限 : {max_per_class}")
print(f"特征维度 : {self.embedding_dim}")
print("=" * 60)
return index
@@ -556,7 +643,17 @@ def main():
# MODEL_PATH = "../model/embedding_20251011_133653/best_embedding_model.pth"
# MODEL_PATH = "../model/ProcessedIngredientRecognition/embedding_20251103_172012/best_embedding_model.pth"
# MODEL_PATH = "../model/WholeIngredientRecognition/cosface_20251113_160103/best_cosface_model.pth"
MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20251204_140816/model_s64.0_m0.4.pth"
#老索引
#MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20251204_140816/model_s64.0_m0.4.pth"
#新索引
#MODEL_PATH = "../model/WholeIngredientRecognition/cosface_20260118_220318/best_cosface_model.pth"
#新索引
#MODEL_PATH = "../model/WholeIngredientRecognition/cosface_20260121_133207/best_cosface_model.pth"
#grid_search最佳模型 s=68, m=0.42 1.22
# MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20260122_112545/best_model_s68.0_m0.42.pth"
# MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20260302_092600/best_model_s64.0_m0.4.pth"
# MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20260407_190824/best_model_s60.0_m0.45.pth"
MODEL_PATH = "../model/WholeIngredientRecognition/grid_search_20260423_175941/best_model_s64.0_m0.45.pth"
# MODEL_PATH = "../model/DishClassification/grid_search_20251121_102723/model_s56.0_m0.4.pth"
# MODEL_PATH = "../model/DishClassification/cosface_20251111_153649/best_cosface_model.pth"
# TRAIN_DIR = "../dataset/ProcessedIngredientRecognition/train"
@@ -567,9 +664,10 @@ def main():
OUTPUT_DIR = "WholeIngredientRecognition/faiss_index"
# OUTPUT_DIR = "DishClassification/faiss_index"
BATCH_SIZE = 16
INDEX_TYPE = 'flat' # 'flat', 'ivf', 'hnsw'
INDEX_TYPE = 'flat' # 'flat', 'ivf', 'hnsw'
EMBEDDING_DIM = 512
MAX_PER_CLASS = 30 # 每类最多保留的代表性样本数(K-Means 筛选上限)
try:
# 构建索引
builder = FAISSIndexBuilder(MODEL_PATH, EMBEDDING_DIM)
@@ -577,7 +675,8 @@ def main():
train_dir=TRAIN_DIR,
output_dir=OUTPUT_DIR,
batch_size=BATCH_SIZE,
index_type=INDEX_TYPE
index_type=INDEX_TYPE,
max_per_class=MAX_PER_CLASS
)
print("\n" + "=" * 60)
+4
View File
@@ -1,3 +1,7 @@
"""
现在基本上不用这个脚本,这个是早期的,现在用toAndroidEmbedding.py
"""
import torch
from net import create_food_cnn, create_mobile_food_cnn
import os
+7 -3
View File
@@ -19,7 +19,8 @@ def main():
base_model = create_mobile_resnet50_embedding(embedding_dim=512, pretrained=True)
# model_path = "../model/DishClassification/grid_search_20251121_102723/model_s56.0_m0.4.pth"
# model_path = "../model/WholeIngredientRecognition/cosface_20251106_134718/best_cosface_model.pth"
model_path = "../model/WholeIngredientRecognition/grid_search_20251204_140816/model_s64.0_m0.4.pth"
model_path = "../model/WholeIngredientRecognition/grid_search_20260423_175941/best_model_s64.0_m0.45.pth"
# model_path = "../model/WholeIngredientRecognition/cosface_20260121_133207/best_cosface_model.pth"
# model_path = "../model/ProcessedIngredientRecognition/embedding_20251029_173607/best_embedding_model.pth"
if not os.path.exists(model_path):
@@ -94,12 +95,15 @@ def main():
# 保存模型,一定不要乱改,防止模型覆盖!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# output_path = "../model/DishClassification/grid_search_20251121_102723/best_embedding_model_mobile.pt"
output_path = "../model/WholeIngredientRecognition/grid_search_20251204_140816/best_embedding_model_mobile.pt"
#原本路径
output_path = "../model/WholeIngredientRecognition/grid_search_20260423_175941/best_embedding_model_mobile.pt"
#新路径
# output_path = "../model/WholeIngredientRecognition/cosface_20260121_133207/best_cosface_model_mobile.pt"
# output_path = "../model/WholeIngredientRecognition/cosface_20251106_134718/best_embedding_model_mobile.pt"
# output_path = "../model/ProcessedIngredientRecognition/embedding_20251029_173607/best_embedding_model_mobile.pt"
traced_model.save(output_path)
print(f"✓ TorchScript模型保存成功: {output_path}")
# 验证保存的模型
loaded_model = torch.jit.load(output_path)
with torch.no_grad():
+2 -2
View File
@@ -57,8 +57,8 @@ GRID_PARAMS = {
'm': [0.32, 0.35, 0.38, 0.40,0.45,0.50], # margin参数
},
'whole_ingredient': {
's': [64.0, 68.0],
'm': [0.40, 0.42, 0.43, 0.45],
's': [60.0,64.0],
'm': [0.42, 0.45],
},
'processed_ingredient': {
's': [56.0, 60.0, 64.0, 68.0],