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