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