feat(core): 实现碗尺寸识别与标定系统
- 新增碗尺寸识别主应用界面,支持图片拖拽、选择和摄像头实时检测 - 界面显示碗的尺寸类别、直径、置信度及算法方法 - 支持计算食物重量,依据秤示数和碗重差值计算 - 实现核心检测模块,采用霍夫圆变换和轮廓法两种检测方法融合 - 检测结果包含尺寸分类和置信度估计,支持绘制调试信息 - 提供交互式标定工具,支持批量加载样本图片并自动检测直径 - 标定工具计算阈值和碗重量,生成并保存配置文件config.json - 界面友好,提供当前状态提示和结果预览功能
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user