feat(core): 实现碗尺寸识别与标定系统

- 新增碗尺寸识别主应用界面,支持图片拖拽、选择和摄像头实时检测
- 界面显示碗的尺寸类别、直径、置信度及算法方法
- 支持计算食物重量,依据秤示数和碗重差值计算
- 实现核心检测模块,采用霍夫圆变换和轮廓法两种检测方法融合
- 检测结果包含尺寸分类和置信度估计,支持绘制调试信息
- 提供交互式标定工具,支持批量加载样本图片并自动检测直径
- 标定工具计算阈值和碗重量,生成并保存配置文件config.json
- 界面友好,提供当前状态提示和结果预览功能
This commit is contained in:
2026-08-26 18:19:45 +08:00
parent 394faf26fc
commit da90a803e4
5 changed files with 1475 additions and 0 deletions
+390
View File
@@ -0,0 +1,390 @@
# 碗尺寸识别模块 (bowl_size)
> 基于纯几何视觉方法的大/中/小碗自动识别方案,无需深度学习模型,轻量、快速、可解释。
---
## 目录
1. [为什么不用深度学习?](#为什么不用深度学习)
2. [核心思路:几何先验](#核心思路几何先验)
3. [检测算法详解](#检测算法详解)
- [预处理:CLAHE 对比度增强](#1-预处理clahe-对比度增强)
- [方法一:霍夫圆变换](#2-方法一霍夫圆变换)
- [方法二:轮廓法](#3-方法二轮廓法)
- [双路融合策略](#4-双路融合策略)
- [自适应降级重试](#5-自适应降级重试)
4. [标定系统](#标定系统)
5. [尺寸分类逻辑](#尺寸分类逻辑)
6. [文件结构](#文件结构)
7. [使用流程](#使用流程)
8. [调参指南](#调参指南)
9. [已知局限性与改进方向](#已知局限性与改进方向)
---
## 为什么不用深度学习?
直觉上,"识别大中小碗"应该用神经网络分类器。但本场景有一个极强的物理先验:
> **摄像头安装高度固定 → 碗的像素直径与真实直径成线性比例关系。**
这意味着只需要量出碗在图像中的像素直径,就可以直接判断是大/中/小碗,而不需要学习任何"视觉特征"。
| 维度 | 深度学习方案 | 本方案(纯几何) |
|------|-------------|----------------|
| 数据需求 | 每类 100+ 张 | **一次标定即可** |
| 部署新秤 | 重新采集 + 训练 | **重跑标定脚本(5分钟)** |
| 可解释性 | 黑盒 | **直接看像素直径** |
| 计算开销 | GPU / 较慢 | **CPU 毫秒级** |
| 适用性 | 高通用性 | 依赖固定安装高度 |
---
## 核心思路:几何先验
```
摄像头(固定高度 H
|
| H 固定
|
┌─────┴─────┐
│ 秤台 │
│ [ 碗 ] │
└───────────┘
真实直径 D(cm) ←→ 像素直径 d(px)
关系:d = k × D (k 是与安装高度相关的比例系数)
```
由于 `k` 对于固定安装的摄像头是常数,我们不需要求出 `k` 的具体值,只需要:
1. **标定阶段**:拍大/中/小碗图片,记录每种碗的 `d`(像素直径)均值
2. **推理阶段**:检测新图像中碗的 `d`,与标定阈值比较,直接输出大/中/小
---
## 检测算法详解
核心代码在 `bowl_detector.py``detect()` 方法中。整体流程:
```
原始图像 (BGR)
1. CLAHE 对比度增强(处理浅色碗+浅色背景)
2. 高斯模糊(去除噪点)
├──► 霍夫圆变换(HoughCircles)────┐
│ │
└──► 轮廓法(Canny + findContours)─┤
▼ 双路结果融合
(两路都失败?→ 宽松参数重试)
▼ 尺寸分类
大 / 中 / 小碗
```
---
### 1. 预处理:CLAHE 对比度增强
**问题**:白色/米色碗放在浅灰色背景上,边缘对比度极低,Canny 检测不到边。
**CLAHE(限制对比度自适应直方图均衡化)** 是一种局部对比度增强算法:
```python
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
```
原理:
- 将图像切成 8×8 的小块(tile)
- 对每个小块独立做直方图均衡化
- `clipLimit=3.0` 限制放大倍数,防止噪点被过度放大
- 相邻块之间做双线性插值,避免块状伪影
效果:即使背景和碗颜色接近,碗边缘的微弱灰度差也会被局部放大,让后续边缘检测能找到碗边。
---
### 2. 方法一:霍夫圆变换
霍夫圆变换(Hough Circle Transform)是 OpenCV 专门用于检测圆形的经典算法。
**参数说明(重要!):**
```python
cv2.HoughCircles(
image,
cv2.HOUGH_GRADIENT,
dp=1.2, # 分辨率倒数比(1=与原图同分辨率,>1降采样加速)
minDist=xxx, # 两个圆心之间的最小距离(防止重复检测同一个碗)
param1=60, # Canny边缘检测的高阈值(内部调用)
param2=25, # 圆心累加器阈值,越小越容易检测到(也越容易误检)
minRadius=xxx, # 最小圆半径(像素)
maxRadius=xxx, # 最大圆半径(像素)
)
```
**`param2` 是最关键的参数**
- 值越大 → 只检测"完美圆",漏检多
- 值越小 → 容易检测到"不完美圆",误检多
- 本方案默认 25,对于碗这种规则形状已经足够
**为什么选最大半径的圆?**
```python
best = max(circles, key=lambda c: c[2]) # c[2] 是半径
```
碗边是最大的圆形轮廓,碗底花纹、碗内装饰线都是更小的圆,取最大的才是碗边。
---
### 3. 方法二:轮廓法
作为霍夫圆的备用方案,用 Canny 边缘检测 + 轮廓分析来找圆。
**步骤:**
```python
# 1. Canny 边缘检测
edges = cv2.Canny(blurred, threshold1=20, threshold2=60)
# 2. 膨胀边缘(让断裂的边缘连接起来)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
edges = cv2.dilate(edges, kernel, iterations=1)
# 3. 找外部轮廓
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
```
**筛选条件:**
1. **面积过滤**:轮廓面积必须在图像面积的 3%~95% 之间(太小是噪点,太大是背景)
2. **圆度过滤**`circularity = 4π × 面积 / 周长²`,值为 1.0 表示完美圆,本方案要求 ≥ 0.5
```
circularity 直觉示例:
圆形 ≈ 1.0
正方形 ≈ 0.785
细长条 ≈ 0.1
```
**评分机制:**
```python
score = circularity × area
```
越圆、越大的轮廓得分越高 → 最终选出最可能是碗边缘的轮廓。
---
### 4. 双路融合策略
```python
def _merge_results(self, hough, contour):
if hough and contour:
# 两路都成功 → 取平均,互相校验,提高精度
d = (hough["diameter"] + contour["diameter"]) / 2
...
return {"method": "hough+contour", ...}
elif hough:
return hough # 只有霍夫成功
elif contour:
return contour # 只有轮廓成功
return {"diameter": 0, ...} # 全部失败
```
两路方法的优缺点互补:
| | 霍夫圆变换 | 轮廓法 |
|--|-----------|-------|
| 擅长 | 圆弧不完整也能检测 | 任意形状,更灵活 |
| 弱点 | 噪声敏感,参数敏感 | 需要轮廓连续完整 |
融合取平均后,单方误差对最终结果的影响减半。
---
### 5. 自适应降级重试
如果两路都失败(比如图像质量极差),自动用更宽松的参数再试一次:
```python
if hough_result is None and contour_result is None:
relaxed_cfg = dict(cfg)
relaxed_cfg["hough_param2"] //= 2 # 更容易检测到圆
relaxed_cfg["canny_threshold1"] //= 2 # 更敏感的边缘检测
relaxed_cfg["min_circularity"] = 0.4 # 允许更不圆的轮廓
# 用宽松参数重试...
```
这是一个"渐进式降级"策略,优先保证精度,实在不行再放宽约束。
---
## 标定系统
标定是本方案的核心步骤,**只需做一次**,永久有效(除非更换摄像头安装高度)。
### 标定原理
```
大碗样本均值: d_large ≈ 420 px
中碗样本均值: d_medium ≈ 320 px
小碗样本均值: d_small ≈ 220 px
小碗阈值上限 = (d_small + d_medium) / 2 = 270 px
中碗阈值上限 = (d_medium + d_large) / 2 = 370 px
推理时:
d ≤ 270 → 小碗
d ≤ 370 → 中碗
d > 370 → 大碗
```
取两档均值的**中点**作为阈值,使得各类别的误判容限最大化。
### 标定数据保存到 config.json
```json
{
"calibrated": true,
"thresholds": {
"small_max_diameter": 270.0,
"medium_max_diameter": 370.0
},
"calibration_averages": {
"small": 220.0,
"medium": 320.0,
"large": 420.0
},
"bowl_weights_grams": {
"small": 180,
"medium": 260,
"large": 350
}
}
```
---
## 尺寸分类逻辑
分类后还会给出一个**置信度**,反映检测到的直径距离阈值边界有多远:
```python
# 以小碗为例:
# d 越远离阈值(small_max),置信度越高
conf = min(1.0, 0.7 + (small_max - d) / small_max × 0.3)
```
置信度范围在 0.7~1.0 之间:
- `1.0`:直径远离边界,非常确定
- `0.7`:直径刚好落在边界附近,较模糊
---
## 文件结构
```
bowl_size/
├── bowl_detector.py # 核心检测类 BowlDetector
│ - detect(image) → BowlDetectionResult
│ - 双路检测 + CLAHE + 自适应降级
├── calibrate.py # 标定 GUICustomTkinter
│ - 加载大/中/小碗图片
│ - 自动检测直径并统计
│ - 计算阈值并写入 config.json
├── app.py # 主应用 GUI
│ - 图片模式:拖拽/选择图片
│ - 摄像头模式:实时检测
│ - 重量计算:秤示数 - 碗重 = 食物重量
├── config.json # 标定配置(标定后自动生成)
│ - 检测参数
│ - 尺寸阈值
│ - 碗的实际重量
└── README.md # 本文件
```
---
## 使用流程
### 第一次使用(标定)
```bash
python bowl_size/calibrate.py
```
1. 选择"小碗"→ 点击"加载该碗型图片"→ 导入 10~20 张小碗图片
2. 重复步骤 1,完成中碗、大碗的图片导入
3. 左侧"检测统计"栏会显示每种碗的平均检测直径
4. 填入各碗的实际重量(克)
5. 点击"✅ 生成标定配置" → 自动写入 `config.json`
> 💡 建议每种碗的图片在实际使用场景中拍摄(有食物、实际光线),这样标定结果更准确。
### 日常使用(识别)
```bash
python bowl_size/app.py
```
- **图片模式**:拖拽图片到预览区,或点击"打开图片"
- **摄像头模式**:点击"开启摄像头",实时检测并显示结果
- **重量计算**:识别成功后,在"秤示数"输入框填入重量,点击"计算食物重量"
---
## 调参指南
如果检测效果不理想,可修改 `config.json` 中的 `detection` 部分:
| 参数 | 作用 | 调大效果 | 调小效果 |
|------|------|---------|---------|
| `hough_param2` | 霍夫圆灵敏度 | 只检测完美圆(漏检多)| 容易误检 |
| `hough_param1` | 内部 Canny 高阈值 | 只检测强边缘 | 边缘更敏感 |
| `canny_threshold2` | 轮廓法 Canny 高阈值 | 只检测强边缘 | 边缘更敏感 |
| `min_circularity` | 轮廓圆度要求 | 只接受更圆的轮廓 | 接受更不规则的形状 |
| `blur_kernel_size` | 高斯模糊强度 | 去除更多噪点(可能模糊边缘)| 保留更多细节 |
**推荐调参顺序:**
1. 先降低 `hough_param2`(如从 25 → 15
2. 再降低 `canny_threshold2`(如从 60 → 40
3. 如果还不行,降低 `min_circularity`(如从 0.5 → 0.4
---
## 已知局限性与改进方向
### 当前局限
| 场景 | 问题 | 影响 |
|------|------|------|
| 碗没放正/严重倾斜 | 投影从圆形变为椭圆,直径偏小 | 可能误判为更小的碗 |
| 多个碗同时在画面中 | 取最大圆,可能选到错误的碗 | 建议每次识别只有一个碗 |
| 碗边被遮挡 > 30% | 轮廓不连续,圆度降低,可能检测失败 | 确保碗边清晰可见 |
| 摄像头高度改变 | 原标定失效 | 需要重新运行 calibrate.py |
### 可能的改进方向
1. **加入 YOLOv8 目标检测**:先 YOLO 定位碗的 bounding box,再在 ROI 内做几何检测,解决多碗场景
2. **椭圆拟合**:将 `minEnclosingCircle` 改为 `fitEllipse`,应对轻微倾斜
3. **结合秤重量信号**:大碗比小碗重,可做双重校验,拒绝明显矛盾的结果
4. **在线自适应**:记录历史检测直径,发现漂移时自动提醒重新标定
---
*本模块是 FoodClassifier 项目的子模块,服务于智慧秤的食物净重计算场景。*
+352
View File
@@ -0,0 +1,352 @@
"""
app.py
碗尺寸识别 - 主应用界面
功能:
- 拖拽或选择图片 → 自动识别大/中/小碗
- 显示检测结果、置信度、碗重
- 支持摄像头实时检测
- 未标定时提示用户先运行 calibrate.py
"""
import os
import sys
import threading
import cv2
import numpy as np
import customtkinter as ctk
from tkinter import filedialog, messagebox
from PIL import Image
from tkinterdnd2 import DND_FILES, TkinterDnD
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bowl_size.bowl_detector import BowlDetector, BowlSize
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PREVIEW_MAX = (560, 560)
class BowlSizeApp:
def __init__(self, root):
self.root = root
self.root.title("碗尺寸识别系统")
self.root.geometry("1100x700")
self.root.resizable(True, True)
self.detector = BowlDetector(CONFIG_PATH)
self.camera_thread = None
self.camera_running = False
self.cap = None
self.current_image_path = None
self._build_ui()
self._check_calibration()
# ---------------------------------------------------------------- #
# UI 构建
# ---------------------------------------------------------------- #
def _build_ui(self):
self.root.grid_rowconfigure(0, weight=1)
self.root.grid_columnconfigure(0, weight=1)
main = ctk.CTkFrame(self.root)
main.grid(row=0, column=0, padx=15, pady=15, sticky="nsew")
main.grid_rowconfigure(1, weight=1)
main.grid_columnconfigure(1, weight=1)
# ── 标题栏 ──────────────────────────────────────────────────
header = ctk.CTkFrame(main, height=55)
header.grid(row=0, column=0, columnspan=2, padx=10, pady=(10, 5), sticky="ew")
header.grid_propagate(False)
ctk.CTkLabel(header, text="🍜 碗尺寸识别系统",
font=ctk.CTkFont(size=22, weight="bold")).pack(
side="left", padx=20, pady=8)
ctk.CTkButton(header, text="⚙ 重新标定", width=110,
command=self._open_calibrate).pack(side="right", padx=10, pady=8)
self.calibration_status_lbl = ctk.CTkLabel(
header, text="", font=ctk.CTkFont(size=12))
self.calibration_status_lbl.pack(side="right", padx=10)
# ── 左侧控制 ─────────────────────────────────────────────────
left = ctk.CTkFrame(main, width=240)
left.grid(row=1, column=0, padx=(10, 5), pady=5, sticky="nsew")
left.grid_propagate(False)
self._build_left(left)
# ── 右侧主区域 ────────────────────────────────────────────────
right = ctk.CTkFrame(main)
right.grid(row=1, column=1, padx=(5, 10), pady=5, sticky="nsew")
right.grid_rowconfigure(0, weight=1)
right.grid_columnconfigure(0, weight=1)
self._build_right(right)
def _build_left(self, parent):
parent.grid_columnconfigure(0, weight=1)
row = 0
# 模式选择
ctk.CTkLabel(parent, text="检测模式",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(15, 5), sticky="w")
row += 1
ctk.CTkButton(parent, text="📂 打开图片", height=40,
command=self._open_image).grid(
row=row, column=0, padx=15, pady=4, sticky="ew")
row += 1
self.camera_btn = ctk.CTkButton(
parent, text="📷 开启摄像头", height=40,
command=self._toggle_camera)
self.camera_btn.grid(row=row, column=0, padx=15, pady=4, sticky="ew")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=12, sticky="ew")
row += 1
# 结果展示区
ctk.CTkLabel(parent, text="识别结果",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(0, 5), sticky="w")
row += 1
result_card = ctk.CTkFrame(parent, corner_radius=10)
result_card.grid(row=row, column=0, padx=15, pady=5, sticky="ew")
result_card.grid_columnconfigure(0, weight=1)
self.size_label = ctk.CTkLabel(
result_card, text="",
font=ctk.CTkFont(size=38, weight="bold"), text_color="#4C9BE8")
self.size_label.grid(row=0, column=0, pady=(15, 5))
self.diameter_label = ctk.CTkLabel(
result_card, text="直径:— px",
font=ctk.CTkFont(size=13), text_color="gray60")
self.diameter_label.grid(row=1, column=0, pady=2)
self.confidence_label = ctk.CTkLabel(
result_card, text="置信度:—",
font=ctk.CTkFont(size=13), text_color="gray60")
self.confidence_label.grid(row=2, column=0, pady=2)
self.method_label = ctk.CTkLabel(
result_card, text="方法:—",
font=ctk.CTkFont(size=11), text_color="gray50")
self.method_label.grid(row=3, column=0, pady=(2, 5))
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=12, sticky="ew")
row += 1
# 碗重 / 食物重量
ctk.CTkLabel(parent, text="重量计算",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(0, 5), sticky="w")
row += 1
weight_card = ctk.CTkFrame(parent, corner_radius=10)
weight_card.grid(row=row, column=0, padx=15, pady=5, sticky="ew")
weight_card.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(weight_card, text="秤示数(g)", width=90, anchor="w").grid(
row=0, column=0, padx=(10, 0), pady=(10, 3))
self.scale_entry = ctk.CTkEntry(weight_card, placeholder_text="输入秤的示数")
self.scale_entry.grid(row=0, column=1, padx=(0, 10), pady=(10, 3), sticky="ew")
ctk.CTkButton(weight_card, text="计算食物重量", height=35,
command=self._calc_food_weight).grid(
row=1, column=0, columnspan=2, padx=10, pady=(5, 5), sticky="ew")
self.bowl_weight_label = ctk.CTkLabel(
weight_card, text="碗重:— g",
font=ctk.CTkFont(size=12), text_color="gray60")
self.bowl_weight_label.grid(row=2, column=0, columnspan=2, pady=2)
self.food_weight_label = ctk.CTkLabel(
weight_card, text="食物重量:— g",
font=ctk.CTkFont(size=14, weight="bold"), text_color="#2ECC71")
self.food_weight_label.grid(row=3, column=0, columnspan=2, pady=(2, 10))
row += 1
# 状态栏
self.status_label = ctk.CTkLabel(
parent, text="请打开图片或开启摄像头",
font=ctk.CTkFont(size=11), text_color="gray60", wraplength=200)
self.status_label.grid(row=row, column=0, padx=15, pady=10, sticky="sw")
def _build_right(self, parent):
self.preview_label = ctk.CTkLabel(
parent, text="拖拽图片到此处,或使用左侧按钮打开/摄像头",
font=ctk.CTkFont(size=14), text_color="gray50",
image=None)
self.preview_label.grid(row=0, column=0, sticky="nsew")
# 拖拽支持
try:
self.preview_label.drop_target_register(DND_FILES)
self.preview_label.dnd_bind("<<Drop>>", self._on_drop)
except Exception:
pass # tkinterdnd2 not available
# ---------------------------------------------------------------- #
# 事件处理
# ---------------------------------------------------------------- #
def _check_calibration(self):
if self.detector.is_calibrated():
self.calibration_status_lbl.configure(
text="✅ 已标定", text_color="green")
else:
self.calibration_status_lbl.configure(
text="⚠ 未标定,请先运行标定工具", text_color="orange")
def _open_image(self):
path = filedialog.askopenfilename(
title="选择图片",
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp *.webp"), ("所有文件", "*.*")]
)
if path:
self._detect_file(path)
def _on_drop(self, event):
path = event.data.strip().strip("{}")
if os.path.isfile(path):
self._detect_file(path)
def _detect_file(self, path: str):
self.current_image_path = path
self._set_status("正在检测...")
def worker():
result = self.detector.detect(path, draw_debug=True)
self.root.after(0, lambda: self._show_result(result))
threading.Thread(target=worker, daemon=True).start()
def _show_result(self, result):
# 更新预览图
if result.debug_image is not None:
self._update_preview(result.debug_image)
# 更新结果
if result.is_valid:
self.size_label.configure(text=result.label, text_color="#4C9BE8")
self.diameter_label.configure(
text=f"直径:{result.pixel_diameter:.0f} px", text_color="gray60")
self.confidence_label.configure(
text=f"置信度:{result.confidence:.0%}", text_color="gray60")
self.method_label.configure(
text=f"方法:{result.method}", text_color="gray50")
bowl_w = self.detector.get_bowl_weight(result.size)
self.bowl_weight_label.configure(text=f"碗重:{bowl_w:.0f} g")
self._set_status(f"检测完成:{result.label}")
else:
self.size_label.configure(text="未检测到", text_color="gray50")
self.diameter_label.configure(text="直径:— px")
self.confidence_label.configure(text="置信度:—")
self.method_label.configure(text="方法:—")
self.bowl_weight_label.configure(text="碗重:— g")
self._set_status("⚠ 未能检测到碗,请检查图片或调整参数")
self._last_result = result
def _calc_food_weight(self):
if not hasattr(self, "_last_result") or not self._last_result.is_valid:
messagebox.showwarning("提示", "请先识别碗的尺寸!")
return
try:
total = float(self.scale_entry.get())
except ValueError:
messagebox.showerror("错误", "请输入有效的秤示数(数字)!")
return
bowl_w = self.detector.get_bowl_weight(self._last_result.size)
food_w = max(0.0, total - bowl_w)
self.food_weight_label.configure(
text=f"食物重量:{food_w:.1f} g", text_color="#2ECC71")
def _toggle_camera(self):
if self.camera_running:
self._stop_camera()
else:
self._start_camera()
def _start_camera(self):
self.cap = cv2.VideoCapture(0)
if not self.cap.isOpened():
messagebox.showerror("错误", "无法打开摄像头!")
return
self.camera_running = True
self.camera_btn.configure(text="⏹ 关闭摄像头", fg_color="red", hover_color="darkred")
self._set_status("摄像头已开启,实时检测中...")
def loop():
while self.camera_running:
ret, frame = self.cap.read()
if not ret:
break
result = self.detector.detect(frame, draw_debug=True)
if result.debug_image is not None:
self.root.after(0, lambda f=result.debug_image: self._update_preview(f))
self.root.after(0, lambda r=result: self._show_result_fast(r))
self.cap.release()
self.camera_thread = threading.Thread(target=loop, daemon=True)
self.camera_thread.start()
def _stop_camera(self):
self.camera_running = False
self.camera_btn.configure(text="📷 开启摄像头",
fg_color=ctk.ThemeManager.theme["CTkButton"]["fg_color"],
hover_color=ctk.ThemeManager.theme["CTkButton"]["hover_color"])
self._set_status("摄像头已关闭")
def _show_result_fast(self, result):
"""摄像头模式下轻量更新(不更新预览图,已在 loop 里处理)"""
if result.is_valid:
self.size_label.configure(text=result.label, text_color="#4C9BE8")
self.diameter_label.configure(text=f"直径:{result.pixel_diameter:.0f} px")
self.confidence_label.configure(text=f"置信度:{result.confidence:.0%}")
bowl_w = self.detector.get_bowl_weight(result.size)
self.bowl_weight_label.configure(text=f"碗重:{bowl_w:.0f} g")
else:
self.size_label.configure(text="", text_color="gray50")
def _update_preview(self, bgr_img: np.ndarray):
rgb = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
pil.thumbnail(PREVIEW_MAX, Image.LANCZOS)
ctk_img = ctk.CTkImage(pil, size=pil.size)
self.preview_label.configure(image=ctk_img, text="")
self.preview_label.image = ctk_img
def _open_calibrate(self):
import subprocess
calibrate_path = os.path.join(os.path.dirname(__file__), "calibrate.py")
subprocess.Popen([sys.executable, calibrate_path])
def _set_status(self, text: str):
self.status_label.configure(text=text)
def main():
root = TkinterDnD.Tk()
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
root.title("碗尺寸识别系统")
root.geometry("1100x700")
app = BowlSizeApp(root)
root.mainloop()
if __name__ == "__main__":
main()
+246
View File
@@ -0,0 +1,246 @@
"""
bowl_detector.py
碗尺寸检测核心模块
检测逻辑:
1. 霍夫圆变换(HoughCircles)- 最优先,精度高
2. 轮廓法(Contour + 最大类圆轮廓)- 备用
3. 两路结果取平均(如果都成功)- 提高鲁棒性
输入:BGR 图像(numpy array 或 文件路径)
输出:BowlDetectionResult,包含 size_label / pixel_diameter / confidence / debug_image
"""
import os
import json
import math
import cv2
import numpy as np
from dataclasses import dataclass
from typing import Optional, Tuple
from enum import Enum
class BowlSize(Enum):
SMALL = "小碗"
MEDIUM = "中碗"
LARGE = "大碗"
UNKNOWN = "未知"
@dataclass
class BowlDetectionResult:
size: BowlSize = BowlSize.UNKNOWN
pixel_diameter: float = 0.0
confidence: float = 0.0
method: str = "none"
center: Tuple[int, int] = (0, 0)
debug_image: Optional[object] = None
@property
def label(self) -> str:
return self.size.value
@property
def is_valid(self) -> bool:
"""直径检测成功即视为有效(未标定时 size=UNKNOWN 但直径仍可用于标定)"""
return self.pixel_diameter > 0
@property
def is_classified(self) -> bool:
"""不仅检测到直径,且完成了尺寸分类"""
return self.size != BowlSize.UNKNOWN and self.pixel_diameter > 0
class BowlDetector:
DEFAULT_CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def __init__(self, config_path: Optional[str] = None):
self.config_path = config_path or self.DEFAULT_CONFIG_PATH
self.config = self._load_config()
def detect(self, image_input, draw_debug: bool = True) -> BowlDetectionResult:
img = self._load_image(image_input)
if img is None:
return BowlDetectionResult()
h, w = img.shape[:2]
cfg = self.config.get("detection", {})
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# CLAHE 对比度增强 —— 解决浅色碗+浅色背景对比度低的问题
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
enhanced = clahe.apply(gray)
blur_k = cfg.get("blur_kernel_size", 11)
blur_k = blur_k if blur_k % 2 == 1 else blur_k + 1
blurred = cv2.GaussianBlur(enhanced, (blur_k, blur_k), 0)
hough_result = self._detect_by_hough(blurred, w, h, cfg)
contour_result = self._detect_by_contour(blurred, w, h, cfg)
# 如果两路都失败,用更宽松参数再试一次(备用策略)
if hough_result is None and contour_result is None:
relaxed_cfg = dict(cfg)
relaxed_cfg["hough_param2"] = max(10, cfg.get("hough_param2", 25) // 2)
relaxed_cfg["canny_threshold1"] = max(5, cfg.get("canny_threshold1", 20) // 2)
relaxed_cfg["canny_threshold2"] = max(20, cfg.get("canny_threshold2", 60) // 2)
relaxed_cfg["min_circularity"] = 0.4
hough_result = self._detect_by_hough(blurred, w, h, relaxed_cfg)
contour_result = self._detect_by_contour(blurred, w, h, relaxed_cfg)
merged = self._merge_results(hough_result, contour_result)
if merged["diameter"] > 0:
size, confidence = self._classify_size(merged["diameter"])
cx, cy = merged["center"]
radius = int(merged["diameter"] / 2)
detection = BowlDetectionResult(
size=size,
pixel_diameter=merged["diameter"],
confidence=confidence,
method=merged["method"],
center=(cx, cy),
)
if draw_debug:
detection.debug_image = self._draw_debug(img.copy(), cx, cy, radius, detection)
else:
detection = BowlDetectionResult()
if draw_debug:
detection.debug_image = img.copy()
return detection
def reload_config(self):
self.config = self._load_config()
def is_calibrated(self) -> bool:
return self.config.get("calibrated", False)
def get_bowl_weight(self, size: BowlSize) -> float:
key = {BowlSize.SMALL: "small", BowlSize.MEDIUM: "medium", BowlSize.LARGE: "large"}.get(size, "")
return self.config.get("bowl_weights_grams", {}).get(key, 0.0)
def _load_config(self) -> dict:
if os.path.exists(self.config_path):
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
return {}
def _load_image(self, image_input) -> Optional[object]:
if isinstance(image_input, str):
return cv2.imdecode(np.fromfile(image_input, dtype=np.uint8), cv2.IMREAD_COLOR)
elif isinstance(image_input, np.ndarray):
return image_input.copy()
return None
def _detect_by_hough(self, blurred_gray, w, h, cfg) -> Optional[dict]:
min_r = int(min(w, h) * cfg.get("hough_min_radius_ratio", 0.05))
max_r = int(min(w, h) * cfg.get("hough_max_radius_ratio", 0.50))
min_dist = int(min(w, h) * cfg.get("hough_min_dist_ratio", 0.30))
circles = cv2.HoughCircles(
blurred_gray, cv2.HOUGH_GRADIENT,
dp=cfg.get("hough_dp", 1.2), minDist=min_dist,
param1=cfg.get("hough_param1", 80), param2=cfg.get("hough_param2", 35),
minRadius=min_r, maxRadius=max_r,
)
if circles is not None:
circles = np.round(circles[0, :]).astype(int)
best = max(circles, key=lambda c: c[2])
cx, cy, r = best
return {"center": (cx, cy), "diameter": float(r * 2), "method": "hough"}
return None
def _detect_by_contour(self, blurred_gray, w, h, cfg) -> Optional[dict]:
edges = cv2.Canny(blurred_gray, cfg.get("canny_threshold1", 30), cfg.get("canny_threshold2", 100))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
edges = cv2.dilate(edges, kernel, iterations=1)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
img_area = w * h
min_area = img_area * cfg.get("min_area_ratio", 0.05)
max_area = img_area * cfg.get("max_area_ratio", 0.95)
min_circ = cfg.get("min_circularity", 0.60)
best_score, best_info = -1, None
for cnt in contours:
area = cv2.contourArea(cnt)
if area < min_area or area > max_area:
continue
perimeter = cv2.arcLength(cnt, True)
if perimeter == 0:
continue
circularity = 4 * math.pi * area / (perimeter ** 2)
if circularity < min_circ:
continue
(cx, cy), radius = cv2.minEnclosingCircle(cnt)
score = circularity * area
if score > best_score:
best_score = score
best_info = {"center": (int(cx), int(cy)), "diameter": float(radius * 2), "method": "contour"}
return best_info
def _merge_results(self, hough, contour) -> dict:
if hough and contour:
d = (hough["diameter"] + contour["diameter"]) / 2
cx = (hough["center"][0] + contour["center"][0]) // 2
cy = (hough["center"][1] + contour["center"][1]) // 2
return {"center": (cx, cy), "diameter": d, "method": "hough+contour"}
elif hough:
return hough
elif contour:
return contour
return {"center": (0, 0), "diameter": 0.0, "method": "none"}
def _classify_size(self, diameter: float) -> Tuple[BowlSize, float]:
thresholds = self.config.get("thresholds", {})
small_max = thresholds.get("small_max_diameter", 0)
medium_max = thresholds.get("medium_max_diameter", 0)
if not self.is_calibrated() or small_max == 0 or medium_max == 0:
return BowlSize.UNKNOWN, 0.0
if diameter <= small_max:
conf = min(1.0, 0.7 + (small_max - diameter) / small_max * 0.3)
return BowlSize.SMALL, round(conf, 3)
elif diameter <= medium_max:
margin = medium_max - small_max
conf = min(1.0, 0.7 + min(diameter - small_max, medium_max - diameter) / margin * 0.3)
return BowlSize.MEDIUM, round(conf, 3)
else:
conf = min(1.0, 0.7 + (diameter - medium_max) / medium_max * 0.3)
return BowlSize.LARGE, round(conf, 3)
def _draw_debug(self, img, cx, cy, radius, result) -> object:
color_map = {
BowlSize.SMALL: (0, 200, 100),
BowlSize.MEDIUM: (0, 165, 255),
BowlSize.LARGE: (0, 80, 255),
BowlSize.UNKNOWN: (128, 128, 128),
}
color = color_map[result.size]
cv2.circle(img, (cx, cy), radius, color, 3)
cv2.circle(img, (cx, cy), 5, color, -1)
cv2.line(img, (cx - radius, cy), (cx + radius, cy), color, 1)
lines = [
f"{result.label}",
f"Diameter: {result.pixel_diameter:.0f}px",
f"Confidence: {result.confidence:.0%}",
f"Method: {result.method}",
]
for i, line in enumerate(lines):
y = 35 + i * 30
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 0, 0), 4, cv2.LINE_AA)
cv2.putText(img, line, (10, y), cv2.FONT_HERSHEY_SIMPLEX, 0.85, color, 2, cv2.LINE_AA)
return img
_default_detector: Optional[BowlDetector] = None
def detect_bowl_size(image_input, config_path: Optional[str] = None) -> BowlDetectionResult:
global _default_detector
if _default_detector is None or config_path:
_default_detector = BowlDetector(config_path)
return _default_detector.detect(image_input)
+377
View File
@@ -0,0 +1,377 @@
"""
calibrate.py
碗尺寸标定工具 - 交互式 GUI
使用流程:
1. 分别为大/中/小碗加载若干样本图片
2. 工具自动检测每张图的碗直径并显示预览
3. 确认后计算阈值并写入 config.json
4. 可选:输入各碗实际重量(克)
"""
import os
import sys
import json
import threading
import cv2
import numpy as np
import customtkinter as ctk
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
# 确保能找到项目根目录下的模块
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bowl_size.bowl_detector import BowlDetector, BowlSize
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("blue")
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
PREVIEW_SIZE = (300, 300)
class CalibrationApp:
def __init__(self, root):
self.root = root
self.root.title("碗尺寸标定工具")
self.root.geometry("1100x750")
self.root.resizable(True, True)
self.detector = BowlDetector(CONFIG_PATH)
# 各尺寸的检测直径列表
self.samples = {"small": [], "medium": [], "large": []}
self.sample_images = {"small": [], "medium": [], "large": []}
self.current_size = "small"
self._build_ui()
# ---------------------------------------------------------------- #
# UI 构建
# ---------------------------------------------------------------- #
def _build_ui(self):
self.root.grid_rowconfigure(0, weight=1)
self.root.grid_columnconfigure(0, weight=1)
main = ctk.CTkFrame(self.root)
main.grid(row=0, column=0, padx=15, pady=15, sticky="nsew")
main.grid_rowconfigure(1, weight=1)
main.grid_columnconfigure(0, weight=1)
main.grid_columnconfigure(1, weight=2)
# ── 标题 ────────────────────────────────────────────────────
title = ctk.CTkLabel(main, text="碗尺寸标定工具",
font=ctk.CTkFont(size=22, weight="bold"))
title.grid(row=0, column=0, columnspan=2, pady=(10, 15))
# ── 左侧控制面板 ─────────────────────────────────────────────
left = ctk.CTkFrame(main)
left.grid(row=1, column=0, padx=(10, 5), pady=10, sticky="nsew")
left.grid_columnconfigure(0, weight=1)
self._build_left_panel(left)
# ── 右侧预览区域 ─────────────────────────────────────────────
right = ctk.CTkFrame(main)
right.grid(row=1, column=1, padx=(5, 10), pady=10, sticky="nsew")
right.grid_rowconfigure(1, weight=1)
right.grid_columnconfigure(0, weight=1)
self._build_right_panel(right)
def _build_left_panel(self, parent):
row = 0
# 当前选择的碗尺寸
ctk.CTkLabel(parent, text="当前标定碗型:",
font=ctk.CTkFont(size=14, weight="bold")).grid(
row=row, column=0, padx=15, pady=(15, 5), sticky="w")
row += 1
self.size_var = ctk.StringVar(value="small")
size_options = [("小碗 (Small)", "small"),
("中碗 (Medium)", "medium"),
("大碗 (Large)", "large")]
for text, val in size_options:
rb = ctk.CTkRadioButton(parent, text=text, variable=self.size_var,
value=val, command=self._on_size_changed)
rb.grid(row=row, column=0, padx=25, pady=3, sticky="w")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 加载图片按钮
ctk.CTkButton(parent, text="📂 加载该碗型图片", height=40,
command=self._load_images).grid(
row=row, column=0, padx=15, pady=5, sticky="ew")
row += 1
ctk.CTkButton(parent, text="🗑 清除该碗型数据", height=35,
fg_color="gray40", hover_color="gray30",
command=self._clear_current).grid(
row=row, column=0, padx=15, pady=5, sticky="ew")
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 各尺寸统计
ctk.CTkLabel(parent, text="检测统计:",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(5, 3), sticky="w")
row += 1
self.stat_labels = {}
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
lbl = ctk.CTkLabel(parent, text=f"{name}: 0 张,均值 0px",
font=ctk.CTkFont(size=12), text_color="gray60")
lbl.grid(row=row, column=0, padx=25, pady=2, sticky="w")
self.stat_labels[sz] = lbl
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 碗重量输入
ctk.CTkLabel(parent, text="碗的重量(克):",
font=ctk.CTkFont(size=13, weight="bold")).grid(
row=row, column=0, padx=15, pady=(5, 3), sticky="w")
row += 1
self.weight_entries = {}
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
frame = ctk.CTkFrame(parent, fg_color="transparent")
frame.grid(row=row, column=0, padx=15, pady=2, sticky="ew")
frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(frame, text=f"{name}: ", width=55).grid(row=0, column=0, sticky="w")
entry = ctk.CTkEntry(frame, placeholder_text="0", width=80)
entry.grid(row=0, column=1, sticky="ew")
ctk.CTkLabel(frame, text="").grid(row=0, column=2, sticky="w")
self.weight_entries[sz] = entry
row += 1
ctk.CTkFrame(parent, height=1, fg_color="gray30").grid(
row=row, column=0, padx=10, pady=10, sticky="ew")
row += 1
# 生成标定按钮
self.calibrate_btn = ctk.CTkButton(
parent, text="✅ 生成标定配置", height=45,
font=ctk.CTkFont(size=14, weight="bold"),
fg_color="#2B8A3E", hover_color="#1F6B2E",
command=self._run_calibration)
self.calibrate_btn.grid(row=row, column=0, padx=15, pady=8, sticky="ew")
row += 1
self.status_label = ctk.CTkLabel(parent, text="请先加载各碗型图片",
text_color="gray60",
font=ctk.CTkFont(size=11),
wraplength=220)
self.status_label.grid(row=row, column=0, padx=15, pady=5, sticky="w")
def _build_right_panel(self, parent):
ctk.CTkLabel(parent, text="图片预览与检测结果",
font=ctk.CTkFont(size=14, weight="bold")).grid(
row=0, column=0, pady=(10, 5))
self.preview_frame = ctk.CTkScrollableFrame(parent)
self.preview_frame.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew")
self.preview_widgets = []
# ---------------------------------------------------------------- #
# 事件处理
# ---------------------------------------------------------------- #
def _on_size_changed(self):
self.current_size = self.size_var.get()
self._refresh_preview()
def _load_images(self):
paths = filedialog.askopenfilenames(
title="选择图片(可多选)",
filetypes=[("图片文件", "*.jpg *.jpeg *.png *.bmp *.webp"), ("所有文件", "*.*")]
)
if not paths:
return
sz = self.size_var.get()
self._set_status(f"正在检测 {len(paths)} 张图片...")
def worker():
for path in paths:
result = self.detector.detect(path, draw_debug=True)
# 标定阶段:只要检测到直径就算成功(不需要完成分类)
if result.pixel_diameter > 0:
self.samples[sz].append(result.pixel_diameter)
self.sample_images[sz].append((path, result))
self.root.after(0, self._after_load)
threading.Thread(target=worker, daemon=True).start()
def _after_load(self):
self._update_stats()
self._refresh_preview()
self._set_status("加载完成,请继续加载其他碗型或生成标定配置。")
def _clear_current(self):
sz = self.size_var.get()
self.samples[sz].clear()
self.sample_images[sz].clear()
self._update_stats()
self._refresh_preview()
def _run_calibration(self):
for sz in ["small", "medium", "large"]:
if not self.samples[sz]:
messagebox.showwarning("数据不足",
f"{'小中大'[['small','medium','large'].index(sz)]}碗还没有有效样本!")
return
small_avg = np.mean(self.samples["small"])
medium_avg = np.mean(self.samples["medium"])
large_avg = np.mean(self.samples["large"])
# 确保顺序正确
sorted_avgs = sorted([(small_avg, "small"), (medium_avg, "medium"), (large_avg, "large")],
key=lambda x: x[0])
names = [x[1] for x in sorted_avgs]
avgs = [x[0] for x in sorted_avgs]
if names != ["small", "medium", "large"]:
messagebox.showwarning("数据异常",
f"检测到的碗尺寸顺序异常:\n小碗均值={small_avg:.0f}px\n"
f"中碗均值={medium_avg:.0f}px\n大碗均值={large_avg:.0f}px\n\n"
"请检查图片是否正确分类。")
return
# 阈值取相邻两档的中点
small_max = (avgs[0] + avgs[1]) / 2
medium_max = (avgs[1] + avgs[2]) / 2
# 读取碗重
weights = {}
for sz in ["small", "medium", "large"]:
try:
weights[sz] = float(self.weight_entries[sz].get() or "0")
except ValueError:
weights[sz] = 0.0
# 加载并更新配置
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
config = json.load(f)
else:
config = {}
config["calibrated"] = True
config["thresholds"] = {
"small_max_diameter": round(small_max, 1),
"medium_max_diameter": round(medium_max, 1),
"description": "像素直径阈值:<=small_max为小碗,<=medium_max为中碗,其余为大碗",
}
config["calibration_samples"] = {
"small": [round(d, 1) for d in self.samples["small"]],
"medium": [round(d, 1) for d in self.samples["medium"]],
"large": [round(d, 1) for d in self.samples["large"]],
}
config["calibration_averages"] = {
"small": round(small_avg, 1),
"medium": round(medium_avg, 1),
"large": round(large_avg, 1),
}
config["bowl_weights_grams"] = weights
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo(
"标定成功",
f"标定完成!配置已保存到 config.json\n\n"
f"小碗均值: {small_avg:.0f}px\n"
f"中碗均值: {medium_avg:.0f}px\n"
f"大碗均值: {large_avg:.0f}px\n\n"
f"小碗阈值上限: {small_max:.0f}px\n"
f"中碗阈值上限: {medium_max:.0f}px"
)
self._set_status("✅ 标定配置已保存!")
# ---------------------------------------------------------------- #
# 辅助方法
# ---------------------------------------------------------------- #
def _update_stats(self):
for sz, name in [("small", "小碗"), ("medium", "中碗"), ("large", "大碗")]:
diameters = self.samples[sz]
n = len(diameters)
if n > 0:
avg = np.mean(diameters)
text = f"{name}: {n} 张,均值 {avg:.0f}px"
self.stat_labels[sz].configure(text=text, text_color="green")
else:
self.stat_labels[sz].configure(text=f"{name}: 0 张", text_color="gray60")
def _refresh_preview(self):
for w in self.preview_widgets:
w.destroy()
self.preview_widgets.clear()
sz = self.size_var.get()
items = self.sample_images.get(sz, [])
if not items:
lbl = ctk.CTkLabel(self.preview_frame, text="暂无图片,请点击左侧[加载]按钮",
text_color="gray60")
lbl.grid(row=0, column=0, padx=20, pady=30)
self.preview_widgets.append(lbl)
return
col_count = 3
for i, (path, result) in enumerate(items):
r, c = divmod(i, col_count)
card = ctk.CTkFrame(self.preview_frame)
card.grid(row=r, column=c, padx=5, pady=5)
# 图片
if result.debug_image is not None:
display_img = result.debug_image
else:
display_img = cv2.imdecode(
np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR)
if display_img is not None:
rgb = cv2.cvtColor(display_img, cv2.COLOR_BGR2RGB)
pil = Image.fromarray(rgb)
pil.thumbnail(PREVIEW_SIZE)
ctk_img = ctk.CTkImage(pil, size=pil.size)
img_lbl = ctk.CTkLabel(card, image=ctk_img, text="")
img_lbl.image = ctk_img
img_lbl.pack(padx=5, pady=(5, 2))
# 标注信息:只要直径 > 0 就算检测成功
if result.pixel_diameter > 0:
info = f"直径: {result.pixel_diameter:.0f}px"
color = "green"
else:
info = "检测失败"
color = "red"
ctk.CTkLabel(card, text=info, text_color=color,
font=ctk.CTkFont(size=11)).pack(padx=5, pady=(0, 5))
self.preview_widgets.append(card)
def _set_status(self, text: str):
self.status_label.configure(text=text)
def main():
root = ctk.CTk()
app = CalibrationApp(root)
root.mainloop()
if __name__ == "__main__":
main()
+110
View File
@@ -0,0 +1,110 @@
{
"_comment": "碗尺寸识别配置文件 - 通过 calibrate.py 标定后自动生成/更新",
"calibrated": true,
"scale_id": "default",
"thresholds": {
"small_max_diameter": 1167.0,
"medium_max_diameter": 1330.0,
"description": "像素直径阈值:<=small_max为小碗,<=medium_max为中碗,其余为大碗"
},
"calibration_samples": {
"small": [
1133.1,
1106.0,
1020.0,
1148.0,
1078.3,
1108.7,
1251.4,
1102.0,
1116.0,
1028.0,
1242.0,
1236.0,
1071.7,
1024.5,
1093.8,
1034.0,
1108.0,
1128.1,
1020.0,
1138.0,
1098.0,
1140.0,
1076.0,
1030.0,
1140.0,
1062.0
],
"medium": [
1258.5,
1314.6,
1174.0,
1172.1,
1180.0,
1331.9,
1251.5,
1255.9,
1237.8,
1182.0,
1250.0,
1220.9,
1271.2,
1250.3,
1213.4,
1213.3,
1185.8,
1223.7,
1192.5,
1194.0,
1182.0,
1255.4,
1252.0
],
"large": [
1358.7,
1496.2,
1344.0,
1494.0,
1464.0,
1497.7,
1494.0,
1480.4,
1503.7,
1490.0,
1403.1,
1348.5,
1434.1,
1334.6,
1442.0,
1350.3,
1490.0,
1354.7,
1412.1
]
},
"detection": {
"min_circularity": 0.5,
"min_area_ratio": 0.03,
"max_area_ratio": 0.95,
"blur_kernel_size": 11,
"canny_threshold1": 20,
"canny_threshold2": 60,
"hough_dp": 1.2,
"hough_min_dist_ratio": 0.3,
"hough_param1": 60,
"hough_param2": 25,
"hough_min_radius_ratio": 0.05,
"hough_max_radius_ratio": 0.5
},
"bowl_weights_grams": {
"small": 10.0,
"medium": 20.0,
"large": 30.0
},
"calibration_averages": {
"small": 1105.1,
"medium": 1228.8,
"large": 1431.2
}
}