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