Files
FoodClassifier/bowl_size/calibrate.py
T
zhangpu da90a803e4 feat(core): 实现碗尺寸识别与标定系统
- 新增碗尺寸识别主应用界面,支持图片拖拽、选择和摄像头实时检测
- 界面显示碗的尺寸类别、直径、置信度及算法方法
- 支持计算食物重量,依据秤示数和碗重差值计算
- 实现核心检测模块,采用霍夫圆变换和轮廓法两种检测方法融合
- 检测结果包含尺寸分类和置信度估计,支持绘制调试信息
- 提供交互式标定工具,支持批量加载样本图片并自动检测直径
- 标定工具计算阈值和碗重量,生成并保存配置文件config.json
- 界面友好,提供当前状态提示和结果预览功能
2026-08-26 18:19:45 +08:00

378 lines
15 KiB
Python

"""
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()