diff --git a/bowl_size/README.md b/bowl_size/README.md new file mode 100644 index 0000000..b9d3f48 --- /dev/null +++ b/bowl_size/README.md @@ -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 项目的子模块,服务于智慧秤的食物净重计算场景。* diff --git a/bowl_size/app.py b/bowl_size/app.py new file mode 100644 index 0000000..b45c4ec --- /dev/null +++ b/bowl_size/app.py @@ -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("<>", 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() diff --git a/bowl_size/bowl_detector.py b/bowl_size/bowl_detector.py new file mode 100644 index 0000000..cda9c67 --- /dev/null +++ b/bowl_size/bowl_detector.py @@ -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) diff --git a/bowl_size/calibrate.py b/bowl_size/calibrate.py new file mode 100644 index 0000000..be7b993 --- /dev/null +++ b/bowl_size/calibrate.py @@ -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() diff --git a/bowl_size/config.json b/bowl_size/config.json new file mode 100644 index 0000000..242d6f0 --- /dev/null +++ b/bowl_size/config.json @@ -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 + } +} \ No newline at end of file