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