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