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

353 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()