Compare commits
23
Commits
486241261c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da90a803e4 | ||
|
|
394faf26fc | ||
|
|
ad541321e5 | ||
|
|
2ae7120bbc | ||
|
|
8ce457a8c8 | ||
|
|
04256c5a40 | ||
|
|
11efe38eeb | ||
|
|
0f85d371ab | ||
|
|
ea1fc21dc4 | ||
|
|
b78032f80c | ||
|
|
b2a5060a19 | ||
|
|
1ff540045d | ||
|
|
dedf72f53c | ||
|
|
8a50e2973b | ||
|
|
7af923f1ce | ||
|
|
7b9a370054 | ||
|
|
9b81a4f4e1 | ||
|
|
2b2ddfc46b | ||
|
|
ae20e428dd | ||
|
|
de106f8ecb | ||
|
|
f60c4d9883 | ||
|
|
d3ab9a6d05 | ||
|
|
d7dd434fec |
@@ -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
|
||||
@@ -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 ≈ 64,margin ≈ 0.35–0.40(因任务而异)。泛化能力更强,为首选方案。
|
||||
- **Triplet + Center Loss**(`train_triplet_embedding.py`):在线难样本挖掘,margin = 0.5,Center Loss 权重因任务而异(0.5–20)。
|
||||
|
||||
### 关键模块
|
||||
|
||||
| 路径 | 功能说明 |
|
||||
|------|---------|
|
||||
| `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` | 主 GUI(CustomTkinter,支持拖拽上传) |
|
||||
| `exp_multimodal/vlm_classifier.py` | 实验性:VLM(Ollama/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 记录下载历史。
|
||||
@@ -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%准确率。
|
||||
@@ -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 # 标定 GUI(CustomTkinter)
|
||||
│ - 加载大/中/小碗图片
|
||||
│ - 自动检测直径并统计
|
||||
│ - 计算阈值并写入 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 项目的子模块,服务于智慧秤的食物净重计算场景。*
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
这个是最早用的一个脚本,采用端到端的训练模式
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import cv2
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
这个脚本主要用于样本增加,主要是前期搜集图片来源于网络
|
||||
收集难度大,为了增加样本多样本,提高模型泛化能力
|
||||
"""
|
||||
import os
|
||||
import random
|
||||
from PIL import Image
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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目录下
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
"丁香羊肉",
|
||||
"丁香鸭",
|
||||
"丁香鸭子",
|
||||
"三丝豆腐",
|
||||
"鱼香肉丝",
|
||||
"宫保鸡丁",
|
||||
"麻婆豆腐",
|
||||
"红烧肉",
|
||||
"糖醋排骨",
|
||||
"北京烤鸭"
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
"""
|
||||
现在基本上不用这个脚本,这个是早期的,现在用toAndroidEmbedding.py
|
||||
"""
|
||||
|
||||
import torch
|
||||
from net import create_food_cnn, create_mobile_food_cnn
|
||||
import os
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user