# coding=utf-8 """待学习库 UI 与识别结果格式化(供各主界面复用)。 本模块以 Mixin 形式提供弹窗,同时被两套界面复用: - ``ui.classifier_ui.BaseDishApp``(卡片式界面) - ``core.common_classifier.BaseClassifierUI``(纯 ttk 界面) 因此这里只依赖双方都具备的成员:``root`` / ``manager`` / ``refresh_template_list`` / ``update_pending_btn``; 对 ``_set_result_text``、``render_result_dock``、``_toast`` 这类增强能力一律做 hasattr 兜底,保证纯 ttk 界面下依然可用。 """ import os import tkinter as tk from tkinter import ttk, messagebox from PIL import Image, ImageTk from ui.theme import COLORS, FONTS, RADIUS from ui.widgets import ( RoundCard, PillButton, Chip, EmptyState, SearchBox, Toast, make_photo_rounded, ) def format_predict_detail(detail): """将 predict_detail 结果格式化为展示文本。""" if not detail: return "识别失败" if detail.get("error"): return detail["error"] return detail.get("text", "无结果") def format_dish_box_label(detail): """将识别结果格式化为图片框标签。 默认只显示菜名;设环境变量 DISH_SHOW_BOX_SCORES=1 时追加「似xx 检xx」。 """ if not detail or detail.get("error"): return "识别失败" if detail.get("is_unknown"): suggested = detail.get("suggested_category") base = f"未知·{suggested}" if suggested else "未知" else: base = detail.get("label") or detail.get("name") or "未知" try: from dao.settings import DISH_SHOW_BOX_SCORES show_scores = bool(DISH_SHOW_BOX_SCORES) except Exception: show_scores = False if not show_scores: return str(base) def _fmt(v): try: return f"{float(v):.2f}" if v is not None else None except (TypeError, ValueError): return None sim = _fmt(detail.get("score")) det = _fmt(detail.get("det_conf")) bits = [] if sim is not None: bits.append(f"似{sim}") if det is not None: bits.append(f"检{det}") if not bits: return str(base) return f"{base} {' '.join(bits)}" # 识别框样式:今日菜单 / 大库 / 未知(RGBA) _STYLE_MENU = { "kind": "menu", "box": (46, 171, 111, 230), # 品牌绿描边 "chip_bg": (232, 248, 239, 240), # 浅绿底 "chip_fg": (36, 150, 95, 255), # 深绿字 "box_width": 3, } _STYLE_BIG = { "kind": "big", "box": (229, 57, 53, 200), # 红描边 "chip_bg": (229, 57, 53, 220), # 红底 "chip_fg": (255, 255, 255, 255), # 白字 "box_width": 2, } _STYLE_UNKNOWN = { "kind": "unknown", "box": (255, 125, 0, 210), # 橙描边 "chip_bg": (255, 125, 0, 220), "chip_fg": (255, 255, 255, 255), "box_width": 2, } def dish_overlay_style(dish): """按是否今日菜单 / 主食 / 未知 返回识别框颜色。""" if not dish or dish.get("is_unknown") or dish.get("error"): return dict(_STYLE_UNKNOWN) if ( dish.get("in_today_menu") or dish.get("match_scope") in ("small", "staple") ): return dict(_STYLE_MENU) return dict(_STYLE_BIG) def resolve_in_today_menu(dish_name, match_scope=None): """识别菜名是否属于今日排餐或主食小库。""" if match_scope in ("small", "staple"): return True name = (dish_name or "").strip() if not name: return False try: from dao.settings import DISH_STAPLE_NAMES if name in set(DISH_STAPLE_NAMES or []): return True except Exception: pass try: from dao.meal_schedule import get_meal_schedule_store return bool(get_meal_schedule_store().is_in_today_menu(name)) except Exception: return False def _period_menu_choices(): """当前餐段菜名列表,供学习下拉。返回 (餐段名, [菜名...], 是否当前餐段)。 有当前餐段 → 只返回该餐段菜品; 不在任何餐段 → 回退今日全部菜品(label=今日菜单)。 """ try: from dao.meal_schedule import get_meal_schedule_store store = get_meal_schedule_store() period = store.current_period() or {} period_names = sorted(store.current_period_dish_names() or []) if period_names: pname = (period.get("mealPeriodName") or "").strip() or "当前餐段" return pname, period_names, True today_names = sorted(store.today_dish_names() or []) return "今日菜单", today_names, False except Exception: return "当前餐段", [], False def _staple_menu_choices(): """主食小库名单(配置表,供学习下拉)。""" try: from dao.settings import DISH_STAPLE_NAMES return sorted({str(n).strip() for n in (DISH_STAPLE_NAMES or []) if str(n).strip()}) except Exception: return [] class PendingLearningUIMixin: """待学习库界面与操作。""" # ---------- 通用小工具(不依赖宿主实现) ---------- def _pl_learn_name_choices(self, suggested=""): """学习下拉候选:主食小库 + 当前餐段菜品(不在餐段时用今日菜单)。""" period_name, period_names, is_period = _period_menu_choices() staples = _staple_menu_choices() values = [] seen = set() for name in staples + list(period_names): if name and name not in seen: values.append(name) seen.add(name) suggested = (suggested or "").strip() if suggested and suggested in values: initial = suggested else: initial = "" return period_name, period_names, values, initial, is_period def _today_menu_info(self): """读取今日排餐展示数据。""" try: from dao.meal_schedule import get_meal_schedule_store return get_meal_schedule_store().periods_for_display() except Exception as exc: return {"error": str(exc), "periods": [], "current_period": None, "date": ""} def _fill_today_menu_body(self, body, info, wraplength=720): """把今日菜单内容填到指定容器(弹窗用)。""" periods = info.get("periods") or [] cur_name = (info.get("current_period") or "").strip() if info.get("error") and not periods: tk.Label(body, text=f"排餐暂不可用:{info.get('error')}", bg=COLORS["surface"], fg=COLORS["warn"], font=FONTS["small"], wraplength=wraplength, justify=tk.LEFT ).pack(anchor=tk.W) return if not periods: tk.Label(body, text="今日暂无排餐数据,学习时仍可手输菜名。", bg=COLORS["surface"], fg=COLORS["muted"], font=FONTS["small"]).pack(anchor=tk.W) return date_txt = info.get("date") or "" head = tk.Frame(body, bg=COLORS["surface"]) head.pack(fill=tk.X, pady=(0, 6)) if date_txt: Chip(head, text=date_txt, tone="mute", bg=COLORS["surface"]).pack(side=tk.LEFT, padx=(0, 6)) if cur_name: Chip(head, text=f"当前:{cur_name}", tone="ok", bg=COLORS["surface"]).pack(side=tk.LEFT) for p in periods: is_cur = bool(p.get("is_current")) row_bg = COLORS["surface_alt"] if is_cur else COLORS["surface"] row = tk.Frame(body, bg=row_bg, highlightbackground=COLORS["border"] if is_cur else COLORS["surface"], highlightthickness=1 if is_cur else 0) row.pack(fill=tk.X, pady=3) pad = tk.Frame(row, bg=row_bg) pad.pack(fill=tk.X, padx=8, pady=6) title = (p.get("name") or "餐段").strip() st = (p.get("startTime") or "").strip() et = (p.get("endTime") or "").strip() time_txt = f"{st}-{et}" if st or et else "" dishes = p.get("dishes") or [] names = [d.get("name") for d in dishes if d.get("name")] title_row = tk.Frame(pad, bg=row_bg) title_row.pack(fill=tk.X) tk.Label(title_row, text=title, bg=row_bg, fg=COLORS["accent"] if is_cur else COLORS["text"], font=FONTS["ui_bold"]).pack(side=tk.LEFT) if time_txt: tk.Label(title_row, text=time_txt, bg=row_bg, fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT, padx=8) Chip(title_row, text=f"{len(names)} 道", tone="ok" if is_cur else "mute", bg=row_bg).pack(side=tk.LEFT) if is_cur: Chip(title_row, text="当前餐段", tone="ok", bg=row_bg).pack(side=tk.LEFT, padx=6) menu_txt = "、".join(names) if names else "(本餐段暂无菜品)" tk.Label(pad, text=menu_txt, bg=row_bg, fg=COLORS["text_body"] if names else COLORS["muted"], font=FONTS["small"], wraplength=wraplength, justify=tk.LEFT, anchor="w").pack(fill=tk.X, pady=(4, 0)) def _show_today_menu_popup(self, parent=None): """弹窗显示今日菜单。""" info = self._today_menu_info() host = parent or self.root win = tk.Toplevel(host) win.title("今日菜单") win.configure(bg=COLORS["bg"]) try: win.transient(host) win.grab_set() except Exception: pass try: win.update_idletasks() sw, sh = win.winfo_screenwidth(), win.winfo_screenheight() width, height = 720, 520 win.geometry(f"{width}x{height}+{(sw - width) // 2}+{max((sh - height) // 2 - 20, 20)}") except Exception: win.geometry("720x520") head = tk.Frame(win, bg=COLORS["bg"]) head.pack(fill=tk.X, padx=16, pady=(14, 6)) tk.Label(head, text="今日菜单", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h1"]).pack(side=tk.LEFT) chips = tk.Frame(head, bg=COLORS["bg"]) chips.pack(side=tk.LEFT, padx=8) def _paint_chips(data): for w in chips.winfo_children(): w.destroy() date_txt = (data.get("date") or "").strip() if date_txt: Chip(chips, text=date_txt, tone="mute", bg=COLORS["bg"]).pack(side=tk.LEFT, padx=(0, 6)) cur_name = (data.get("current_period") or "").strip() if cur_name: Chip(chips, text=f"当前:{cur_name}", tone="ok", bg=COLORS["bg"]).pack(side=tk.LEFT) loaded = (data.get("loaded_at") or "").strip() if loaded: Chip(chips, text=f"拉取 {loaded}", tone="mute", bg=COLORS["bg"]).pack( side=tk.LEFT, padx=(6, 0) ) def _reload_body(data): for w in inner.winfo_children(): w.destroy() self._fill_today_menu_body(inner, data, wraplength=640) _paint_chips(data) def _refresh_meals(): try: from dao.meal_schedule import get_meal_schedule_store import dao.settings as app_settings store = get_meal_schedule_store() result = store.refresh( base_name=getattr(app_settings, "DISH_BASE_NAME", None), force=True, ) except Exception as exc: messagebox.showwarning("刷新排餐", f"拉取失败:{exc}", parent=win) return data = self._today_menu_info() _reload_body(data) if result.get("ok"): self._pl_toast( f"排餐已刷新:{data.get('date') or ''} · {result.get('names', 0)} 道", "ok", ) else: messagebox.showwarning( "刷新排餐", f"拉取失败:{result.get('error') or '未知错误'}\n仍显示本地缓存。", parent=win, ) # 标题栏右侧:显眼的刷新按钮(说明文字里的「刷新排餐」不可点) PillButton( head, text="刷新排餐", variant="primary", size="sm", command=_refresh_meals, tooltip="重新从管理系统拉取今日排餐", ).pack(side=tk.RIGHT) _paint_chips(info) tk.Label( win, text="学习时可从主食/当前餐段菜品下拉选择,也可手输菜名。改菜单后点右上角「刷新排餐」按钮。", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], ).pack(anchor=tk.W, padx=18) card = RoundCard(win, radius=RADIUS["lg"], padding=10, expand_body=True) card.pack(fill=tk.BOTH, expand=True, padx=14, pady=(8, 4)) wrap, inner, _cv = self._pl_scroll_area(card.body, COLORS["surface"]) wrap.pack(fill=tk.BOTH, expand=True) self._fill_today_menu_body(inner, info, wraplength=640) footer = tk.Frame(win, bg=COLORS["bg"]) footer.pack(fill=tk.X, padx=16, pady=(4, 14)) PillButton(footer, text="关闭", variant="ghost", command=win.destroy).pack(side=tk.RIGHT) def _build_today_menu_section(self, parent): """待学习库顶部:仅放「今日菜单」按钮,点击后弹窗查看。""" info = self._today_menu_info() periods = info.get("periods") or [] cur_name = (info.get("current_period") or "").strip() dish_n = 0 for p in periods: if p.get("is_current"): dish_n = len([d for d in (p.get("dishes") or []) if d.get("name")]) break if dish_n == 0 and periods: dish_n = sum(len([d for d in (p.get("dishes") or []) if d.get("name")]) for p in periods) row = tk.Frame(parent, bg=COLORS["bg"]) row.pack(fill=tk.X, padx=14, pady=(8, 2)) btn_txt = "今日菜单" if cur_name: btn_txt = f"今日菜单 · {cur_name}" if dish_n: btn_txt = f"{btn_txt}({dish_n}道)" PillButton( row, text=btn_txt, variant="secondary", size="sm", command=lambda p=parent: self._show_today_menu_popup(p), tooltip="点击查看今日各餐段排餐", ).pack(side=tk.LEFT) tk.Label(row, text="学习时下拉可选主食/当前餐段菜品", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["caption"] ).pack(side=tk.LEFT, padx=10) def _pl_toast(self, text, tone="info"): if hasattr(self, "_toast"): try: self._toast(text, tone) return except Exception: pass try: Toast.show(self.root, text, tone=tone) except Exception: pass def _pl_dialog(self, title, width, height, grab=True): dialog = tk.Toplevel(self.root) dialog.title(title) dialog.configure(bg=COLORS["bg"]) try: dialog.update_idletasks() sw, sh = dialog.winfo_screenwidth(), dialog.winfo_screenheight() width = min(width, max(sw - 80, 400)) height = min(height, max(sh - 100, 300)) dialog.geometry(f"{width}x{height}+{(sw - width) // 2}+{max((sh - height) // 2 - 20, 20)}") except Exception: dialog.geometry(f"{width}x{height}") dialog.transient(self.root) if grab: dialog.grab_set() return dialog def _pl_scroll_area(self, parent, bg): wrap = tk.Frame(parent, bg=bg) canvas = tk.Canvas(wrap, bg=bg, highlightthickness=0, bd=0) bar = ttk.Scrollbar(wrap, orient="vertical", command=canvas.yview) inner = tk.Frame(canvas, bg=bg) inner.bind("", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) win = canvas.create_window((0, 0), window=inner, anchor="nw") canvas.configure(yscrollcommand=bar.set) canvas.bind("", lambda e: canvas.itemconfigure(win, width=max(e.width, 1))) canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) bar.pack(side=tk.RIGHT, fill=tk.Y) if hasattr(self, "_register_scroll"): try: self._register_scroll(canvas) except Exception: pass return wrap, inner, canvas # ---------- 入口按钮 ---------- def setup_pending_learning_ui(self, parent, side=tk.TOP, fill=tk.X, expand=False, padx=0, pady=3): btn = PillButton( parent, text="待学习库 (0)", variant="ghost", icon="📚", command=self.show_pending_dialog, tooltip="识别未达阈值的菜品会自动收录到这里,可命名学习或忽略", ) btn.pack(side=side, fill=fill, expand=expand, padx=padx, pady=pady) self.pending_btn = btn btns = getattr(self, "_pending_btns", None) if not isinstance(btns, list): btns = [] # 清理已销毁的按钮(例如设置页关闭后) alive = [] for old in btns: try: if old.winfo_exists(): alive.append(old) except Exception: pass alive.append(btn) self._pending_btns = alive self.update_pending_btn() def update_pending_btn(self): try: count = self.manager.get_pending_count() except Exception: count = 0 text = f"待学习库 ({count})" variant = "secondary" if count else "ghost" alive = [] for btn in getattr(self, "_pending_btns", []): try: if not btn.winfo_exists(): continue btn.config(text=text) btn.set_variant(variant) alive.append(btn) except Exception: pass self._pending_btns = alive if alive: self.pending_btn = alive[0] elif hasattr(self, "pending_btn"): try: if self.pending_btn.winfo_exists(): self.pending_btn.config(text=text) self.pending_btn.set_variant(variant) except Exception: pass return count def predict_crop(self, image_path, auto_pending=True): return self.manager.predict_detail(image_path, auto_pending=auto_pending) # --------------------------------------------------------------- # 待学习库 # --------------------------------------------------------------- def show_pending_dialog(self): try: items = self.manager.get_pending_list() except Exception as e: messagebox.showerror("错误", f"读取待学习库失败:{e}") return # 首次打开时暂停实时扫描;库内刷新重建弹窗时保留恢复标记 if not getattr(self, "_pending_dialog_open", False): self._pending_resume_live = bool(getattr(self, "_live_enabled", False)) if self._pending_resume_live: self._live_enabled = False job = getattr(self, "_live_job", None) if job is not None: try: self.root.after_cancel(job) except Exception: pass self._live_job = None if hasattr(self, "camera_btn"): try: self.camera_btn.config(text="继续扫描") except Exception: pass self._pending_dialog_open = True dialog = self._pl_dialog("待学习菜品库", 1120, 720) def _close_pending(): self._pending_dialog_open = False try: if dialog.winfo_exists(): dialog.destroy() except Exception: pass try: self.update_pending_btn() except Exception: pass if getattr(self, "_pending_resume_live", False) and getattr(self, "camera_connected", False): self._pending_resume_live = False if hasattr(self, "start_live_scan"): self.start_live_scan() dialog._close_pending = _close_pending dialog.protocol("WM_DELETE_WINDOW", _close_pending) header = tk.Frame(dialog, bg=COLORS["bg"]) header.pack(fill=tk.X, padx=16, pady=(14, 6)) tk.Label(header, text="待学习菜品库", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h1"]).pack(side=tk.LEFT) tk.Label(header, text=f"共 {len(items)} 条记录", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT, padx=10) if items: PillButton(header, text="全部忽略", variant="ghost", size="sm", command=lambda: self._ignore_all_pending(items, dialog), ).pack(side=tk.RIGHT) tk.Label(dialog, text="识别未达阈值的菜品会自动收录到这里;命名学习后会加入模板库。", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], ).pack(anchor=tk.W, padx=18) self._build_today_menu_section(dialog) if items: search_row = tk.Frame(dialog, bg=COLORS["bg"]) search_row.pack(fill=tk.X, padx=16, pady=(10, 4)) search = SearchBox(search_row, placeholder="按菜名 / 最接近类别搜索…", bg=COLORS["bg"], height=32) search.pack(fill=tk.X) card = RoundCard(dialog, radius=RADIUS["lg"], padding=10, expand_body=True) card.pack(fill=tk.BOTH, expand=True, padx=14, pady=(6, 4)) if not items: EmptyState(card.body, text="暂无待学习菜品", hint="识别置信度低于阈值的菜品会被自动收录,便于后续补学", glyph="◍", bg=COLORS["surface"]).pack(fill=tk.BOTH, expand=True) PillButton(dialog, text="关闭", variant="ghost", command=_close_pending).pack(pady=(4, 14)) return wrap, inner, _cv = self._pl_scroll_area(card.body, COLORS["surface"]) wrap.pack(fill=tk.BOTH, expand=True) thumb_refs = [] dialog._thumb_refs = thumb_refs holder = {"render": None} def _render(keyword=""): keyword = (keyword or "").strip().lower() for w in inner.winfo_children(): w.destroy() thumb_refs.clear() shown = 0 for item in items: suggested = item.get("suggested_category") or "" hay = f"{suggested} {item.get('id')}".lower() if keyword and keyword not in hay: continue shown += 1 self._build_pending_row(inner, item, suggested, dialog, thumb_refs) if shown == 0: EmptyState(inner, text="没有匹配的记录", hint=f"未找到包含「{keyword}」的待学习记录", glyph="◌", bg=COLORS["surface"], pady=20).pack(fill=tk.X) if items: search._command = _render search.bind_enter(lambda text: _render(text)) _render("") footer = tk.Frame(dialog, bg=COLORS["bg"]) footer.pack(fill=tk.X, padx=16, pady=(4, 14)) tk.Label(footer, text="提示:点缩略图可查看大图;「学习」会把该图加入所选菜名的模板库。", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["caption"]).pack(side=tk.LEFT) PillButton(footer, text="关闭", variant="ghost", command=_close_pending).pack(side=tk.RIGHT) def _build_pending_row(self, parent, item, suggested, dialog, thumb_refs): box = tk.Frame(parent, bg=COLORS["surface_alt"], highlightbackground=COLORS["border"], highlightthickness=1) box.pack(fill=tk.X, pady=4, padx=2) pad = tk.Frame(box, bg=COLORS["surface_alt"]) pad.pack(fill=tk.X, padx=10, pady=8) img_path = item.get("image_path") if img_path and os.path.exists(img_path): photo = make_photo_rounded(img_path, size=72, radius=RADIUS["md"]) if photo is not None: thumb_refs.append(photo) lbl = tk.Label(pad, image=photo, bg=COLORS["surface_alt"], bd=0, cursor="hand2") lbl.pack(side=tk.LEFT, padx=(0, 12)) lbl.bind("", lambda e, p=img_path: self._preview_pending_image(p, dialog)) else: tk.Label(pad, text="[无图]", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT, padx=(0, 12)) else: tk.Label(pad, text="[无图]", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT, padx=(0, 12)) info = tk.Frame(pad, bg=COLORS["surface_alt"]) info.pack(side=tk.LEFT, fill=tk.X, expand=True) score = item.get("score") score_txt = f"{score:.3f}" if score is not None else "—" title_row = tk.Frame(info, bg=COLORS["surface_alt"]) title_row.pack(fill=tk.X) tk.Label(title_row, text=f"#{item['id']}", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["mono"]).pack(side=tk.LEFT, padx=(0, 8)) tk.Label(title_row, text=f"最接近:{suggested or '无'}", bg=COLORS["surface_alt"], fg=COLORS["text"], font=FONTS["ui_bold"]).pack(side=tk.LEFT) Chip(title_row, text=f"相似度 {score_txt}", tone="warn" if (score or 0) < 0.5 else "mute", bg=COLORS["surface_alt"]).pack(side=tk.LEFT, padx=8) tk.Label(info, text=f"收录时间:{item.get('capture_time', '')}", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["small"], anchor="w").pack(anchor="w", pady=(4, 0)) actions = tk.Frame(pad, bg=COLORS["surface_alt"]) actions.pack(side=tk.RIGHT, padx=(10, 0)) pid = item["id"] PillButton(actions, text="学习", variant="primary", size="sm", command=lambda p=pid, s=suggested: self._learn_pending_from_dialog(p, s, dialog), ).pack(side=tk.LEFT, padx=2) PillButton(actions, text="忽略", variant="ghost", size="sm", command=lambda p=pid: self._ignore_pending_from_dialog(p, dialog), ).pack(side=tk.LEFT, padx=2) # 删除与忽略对列表效果接近,暂隐藏硬删除入口 # PillButton(actions, text="删除", variant="danger", size="sm", # command=lambda p=pid: self._delete_pending_from_dialog(p, dialog), # ).pack(side=tk.LEFT, padx=2) def _ignore_all_pending(self, items, dialog): if not messagebox.askyesno("确认", f"确定忽略全部 {len(items)} 条待学习记录?", parent=dialog): return ok = 0 for item in items: try: if self.manager.ignore_pending(item["id"]): ok += 1 except Exception: continue self.update_pending_btn() try: dialog.destroy() except Exception: pass self._pl_toast(f"已忽略 {ok} 条待学习记录", "ok") # 保持 _pending_dialog_open / _pending_resume_live,重建列表 self.show_pending_dialog() def _preview_pending_image(self, image_path, parent=None): """待学习库:点击缩略图查看对应大图。""" if not image_path or not os.path.exists(image_path): messagebox.showwarning("提示", "图片不存在或已删除", parent=parent or self.root) return try: with open(image_path, "rb") as f: im = Image.open(f).convert("RGB") except Exception as e: messagebox.showerror("错误", f"无法打开图片:{e}", parent=parent or self.root) return win = tk.Toplevel(parent or self.root) win.title("待学习图片预览") win.configure(bg=COLORS["bg"]) win.transient(parent or self.root) im.thumbnail((760, 640)) photo = ImageTk.PhotoImage(im) win._photo = photo tk.Label(win, image=photo, bg=COLORS["bg"]).pack(padx=12, pady=12) tk.Label(win, text=os.path.basename(image_path), bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"]).pack() PillButton(win, text="关闭", variant="ghost", command=win.destroy).pack(pady=(8, 12)) def _learn_pending_from_dialog(self, pending_id, suggested, dialog): """待学习库:下拉含主食小库 + 当前餐段菜品。""" period_name, period_names, values, initial, is_period = self._pl_learn_name_choices(suggested) pick = tk.Toplevel(dialog) pick.title("选择菜品名称") pick.configure(bg=COLORS["bg"]) pick.transient(dialog) pick.grab_set() pick.resizable(False, False) tk.Label(pick, text="学习到哪个菜名?", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h2"]).pack(padx=20, pady=(18, 4), anchor=tk.W) if values and is_period: tip = f"可从主食或当前餐段「{period_name}」下拉选择,也可手动输入菜名。" elif values: tip = f"可从主食或「{period_name}」下拉选择,也可手动输入。" else: tip = "暂无候选菜品可选,请手动输入菜名。" tk.Label(pick, text=tip, bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], wraplength=340, justify=tk.LEFT).pack(padx=20, anchor=tk.W) if values: Chip(pick, text=f"主食+{period_name} · {len(values)} 道", tone="ok", bg=COLORS["bg"]).pack(padx=20, pady=(8, 0), anchor=tk.W) name_var = tk.StringVar(value=initial) combo = ttk.Combobox( pick, textvariable=name_var, values=values, width=34, style="Large.TEntry", ) combo.pack(padx=20, pady=10, fill=tk.X) combo.focus_set() if not values: tk.Label(pick, text="暂无可选菜名,请直接输入新菜名(将新建类别)。", bg=COLORS["bg"], fg=COLORS["warn"], font=FONTS["caption"], wraplength=300, justify=tk.LEFT).pack(padx=20, anchor=tk.W) btns = tk.Frame(pick, bg=COLORS["bg"]) btns.pack(fill=tk.X, padx=20, pady=(10, 18)) def _confirm(): category = (name_var.get() or "").strip() if not category: messagebox.showwarning("提示", "请先选择或输入菜品名称", parent=pick) return ok = self.manager.learn_from_pending(pending_id, category) if ok: pick.destroy() self.refresh_template_list() self.update_pending_btn() self._pl_toast(f"已学习为「{category}」并加入模板库", "ok") dialog.destroy() self.show_pending_dialog() else: messagebox.showerror("失败", "学习失败,请重试", parent=pick) PillButton(btns, text="确定学习", variant="primary", command=_confirm).pack(side=tk.RIGHT, padx=4) PillButton(btns, text="取消", variant="ghost", command=pick.destroy).pack(side=tk.RIGHT) pick.bind("", lambda e: _confirm()) pick.wait_window() def _ignore_pending_from_dialog(self, pending_id, dialog): if self.manager.ignore_pending(pending_id): self.update_pending_btn() dialog.destroy() self._pl_toast("已忽略该条待学习记录", "ok") self.show_pending_dialog() def _delete_pending_from_dialog(self, pending_id, dialog): if messagebox.askyesno("确认", "确定删除该待学习记录?", parent=dialog): self.manager.delete_pending(pending_id) self.update_pending_btn() dialog.destroy() self._pl_toast("已删除该条待学习记录", "ok") self.show_pending_dialog() def notify_unknown_pending(self, count, dishes=None): """兼容旧调用:有 dishes 时走即时确认弹窗,否则仅提示。""" if dishes is not None: self.confirm_unknown_dishes(dishes) return if count <= 0: return self.update_pending_btn() messagebox.showinfo( "待学习提示", f"本次有 {count} 个未知菜品已加入待学习库。\n可在左侧「待学习库」中命名学习。", ) # --------------------------------------------------------------- # 识别后即时确认 # --------------------------------------------------------------- def confirm_unknown_dishes(self, dishes, on_finished=None): """识别后即时确认未知菜:命名学习 / 忽略 / 稍后进待学习库。""" unknowns = [ d for d in (dishes or []) if d.get("is_unknown") and d.get("pending_id") ] self.update_pending_btn() if not unknowns: if on_finished: on_finished(False) return period_name, period_names, learn_values, _, is_period = self._pl_learn_name_choices("") dialog = self._pl_dialog("新菜确认学习", 860, 680) learned_count = {"n": 0} thumb_refs = [] dialog._thumb_refs = thumb_refs head = tk.Frame(dialog, bg=COLORS["bg"]) head.pack(fill=tk.X, padx=16, pady=(14, 4)) tk.Label(head, text="新菜确认学习", bg=COLORS["bg"], fg=COLORS["text"], font=FONTS["h1"]).pack(side=tk.LEFT) Chip(head, text=f"{len(unknowns)} 个待确认", tone="warn", bg=COLORS["bg"] ).pack(side=tk.LEFT, padx=10) if learn_values: Chip(head, text=f"主食+{period_name} {len(learn_values)}道", tone="ok", bg=COLORS["bg"]).pack(side=tk.LEFT) if learn_values and is_period: tip = f"可从主食或当前餐段「{period_name}」下拉选择,也可手输菜名;直接关闭则保留在待学习库。" elif learn_values: tip = f"可从主食或「{period_name}」下拉选择,也可手输菜名;直接关闭则保留在待学习库。" else: tip = "暂无候选菜品,可手输菜名学习;直接关闭则保留在待学习库。" tk.Label( dialog, text=tip, bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], wraplength=780, justify=tk.LEFT, ).pack(anchor=tk.W, padx=18) self._build_today_menu_section(dialog) card = RoundCard(dialog, radius=RADIUS["lg"], padding=10, expand_body=True) card.pack(fill=tk.BOTH, expand=True, padx=14, pady=10) wrap, scroll_frame, _cv = self._pl_scroll_area(card.body, COLORS["surface"]) wrap.pack(fill=tk.BOTH, expand=True) def _mark_row_done(buttons, status_label, text, ok=True): status_label.config(text=text, foreground=("#00B42A" if ok else COLORS["muted"])) for btn in buttons: try: btn.config(state=tk.DISABLED) except Exception: pass def _learn_one(dish, name_var, buttons, status_label): name = (name_var.get() or "").strip() if not name: messagebox.showwarning("提示", "请输入菜品名称", parent=dialog) return pending_id = dish.get("pending_id") ok = self.manager.learn_from_pending(pending_id, name) if not ok: messagebox.showerror("失败", "学习失败,请重试", parent=dialog) return dish["is_unknown"] = False dish["label"] = name dish["suggested_category"] = name dish["text"] = f"{name}(已确认学习)" dish["pending_id"] = None learned_count["n"] += 1 _mark_row_done(buttons, status_label, f"已学习为「{name}」", ok=True) self.refresh_template_list() self.update_pending_btn() self._pl_toast(f"「{name}」已学习并加入模板库", "ok") def _ignore_one(dish, buttons, status_label): pending_id = dish.get("pending_id") if pending_id: self.manager.ignore_pending(pending_id) dish["pending_id"] = None dish["text"] = (dish.get("text") or "未知菜品") + "(已忽略)" _mark_row_done(buttons, status_label, "已忽略(不再进待学习)", ok=False) self.update_pending_btn() for dish in unknowns: box = tk.Frame(scroll_frame, bg=COLORS["surface_alt"], highlightbackground=COLORS["border"], highlightthickness=1) box.pack(fill=tk.X, pady=5, padx=2) pad = tk.Frame(box, bg=COLORS["surface_alt"]) pad.pack(fill=tk.X, padx=10, pady=10) img_path = dish.get("crop_image") photo = make_photo_rounded(img_path, size=84, radius=RADIUS["md"]) \ if img_path and os.path.exists(img_path) else None if photo is not None: thumb_refs.append(photo) tk.Label(pad, image=photo, bg=COLORS["surface_alt"], bd=0 ).pack(side=tk.LEFT, padx=(0, 12)) else: tk.Label(pad, text="[无图]", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT, padx=(0, 12)) mid = tk.Frame(pad, bg=COLORS["surface_alt"]) mid.pack(side=tk.LEFT, fill=tk.X, expand=True) idx = dish.get("index") or "?" score = dish.get("score") score_txt = f"{score:.3f}" if score is not None else "—" suggested = dish.get("suggested_category") or "" meta_row = tk.Frame(mid, bg=COLORS["surface_alt"]) meta_row.pack(fill=tk.X) tk.Label(meta_row, text=f"菜品 {idx}", bg=COLORS["surface_alt"], fg=COLORS["text"], font=FONTS["ui_bold"]).pack(side=tk.LEFT) Chip(meta_row, text=f"相似度 {score_txt}", tone="warn", bg=COLORS["surface_alt"] ).pack(side=tk.LEFT, padx=8) if suggested: tk.Label(meta_row, text=f"最接近:{suggested}", bg=COLORS["surface_alt"], fg=COLORS["muted"], font=FONTS["small"]).pack(side=tk.LEFT) name_row = tk.Frame(mid, bg=COLORS["surface_alt"]) name_row.pack(fill=tk.X, pady=(6, 4)) tk.Label(name_row, text="菜名", bg=COLORS["surface_alt"], fg=COLORS["text_body"], font=FONTS["ui"]).pack(side=tk.LEFT) row_initial = suggested if suggested in learn_values else (suggested or "") name_var = tk.StringVar(value=row_initial) ttk.Combobox( name_row, textvariable=name_var, values=learn_values, width=26, ).pack(side=tk.LEFT, padx=8) status_label = tk.Label(mid, text="待确认", bg=COLORS["surface_alt"], fg=COLORS["warn"], font=FONTS["small"]) status_label.pack(anchor=tk.W) actions = tk.Frame(pad, bg=COLORS["surface_alt"]) actions.pack(side=tk.RIGHT, padx=(10, 0)) buttons = [] learn_btn = PillButton( actions, text="确认学习", variant="primary", size="sm", command=lambda d=dish, nv=name_var, b=buttons, s=status_label: _learn_one(d, nv, b, s), ) ignore_btn = PillButton( actions, text="忽略", variant="ghost", size="sm", command=lambda d=dish, b=buttons, s=status_label: _ignore_one(d, b, s), ) buttons.extend([learn_btn, ignore_btn]) learn_btn.pack(fill=tk.X, pady=2) ignore_btn.pack(fill=tk.X, pady=2) footer = tk.Frame(dialog, bg=COLORS["bg"]) footer.pack(fill=tk.X, padx=16, pady=(0, 14)) def _finish(): if not dialog.winfo_exists(): return remaining = sum(1 for d in unknowns if d.get("pending_id")) self.update_pending_btn() learned = learned_count["n"] # 有结果面板时刷新它,并把多行明细收敛成一行摘要 if hasattr(self, "render_result_dock"): try: self.render_result_dock(dishes) except Exception: pass if hasattr(self, "_set_result_text"): self._set_result_text( f"本次 {len(dishes or [])} 盘|已学习 {learned}|仍待确认 {remaining}", "ok" ) elif hasattr(self, "_set_result_text"): lines = [] for d in dishes or []: idx = d.get("index") or "?" if d.get("is_unknown"): lines.append(f"【菜品{idx}】{d.get('text') or '未知菜品'}") else: score = d.get("score") name = d.get("label") or "未知" if score is not None and "已确认学习" not in (d.get("text") or ""): lines.append(f"【菜品{idx}】{name} 相似度:{score:.3f}") else: lines.append(f"【菜品{idx}】{d.get('text') or name}") self._set_result_text("\n".join(lines), "ok") else: try: self.result_label.config(text=f"已学习 {learned} 个菜品", foreground="green") except Exception: pass dialog.destroy() if learned > 0: self._pl_toast( f"已即时学习 {learned} 个菜品" + (f",另有 {remaining} 个留在待学习库" if remaining else ""), "ok", ) elif remaining > 0: self._pl_toast(f"{remaining} 个未知菜品已留在待学习库,可稍后命名学习", "warn") if on_finished: on_finished(learned > 0) tk.Label(footer, text="不操作直接关闭 = 稍后处理(保留在待学习库)", bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["caption"]).pack(side=tk.LEFT) PillButton(footer, text="完成", variant="primary", command=_finish).pack(side=tk.RIGHT) dialog.protocol("WM_DELETE_WINDOW", _finish) dialog.wait_window() # --------------------------------------------------------------- # 批量识别 # --------------------------------------------------------------- def run_plate_predict_batch( self, source_image_path, cropped_file_paths, boxes, progress_callback=None, auto_pending=True, crop_images=None, det_confs=None, ): """对多个裁剪区域识别。 auto_pending: True — 未知菜写入待学习库(选图/拍照) False — 仅判定未知,不写待学习(实时扫描) cropped_file_paths: 裁剪图路径列表(可与 crop_images 二选一/并用) crop_images: 可选 BGR ndarray 列表,优先用于特征提取(免读盘) det_confs: 可选 YOLO 检测置信度列表(与 boxes 对齐) 返回: final_result, dish_labels, unknown_count, labeled_path, dishes dishes: 供 API 上报的结构化列表(含 feature,便于复用) """ n = len(cropped_file_paths) if cropped_file_paths else (len(crop_images) if crop_images else 0) if crop_images is None: crop_images = [None] * n if not cropped_file_paths: cropped_file_paths = [None] * n # 优先用内存图提特征;无则回退路径 sources = [] for i in range(n): img = crop_images[i] if i < len(crop_images) else None path = cropped_file_paths[i] if i < len(cropped_file_paths) else None sources.append(img if img is not None else path) if progress_callback: progress_callback(0, n) features = self.manager.extract_features_batch(sources) final_result = "" dish_labels = [] dishes = [] kept_boxes = [] unknown_count = 0 for i in range(n): if progress_callback: progress_callback(i + 1, n) box = boxes[i] if boxes and i < len(boxes) else None det_conf = det_confs[i] if det_confs and i < len(det_confs) else None img_path = cropped_file_paths[i] if i < len(cropped_file_paths) else None try: detail = self.manager.predict_from_feature( features[i], image_path=img_path if isinstance(img_path, str) else None, auto_pending=auto_pending, ) # 模板命中手/空盘等:整框丢弃 if detail.get("is_filtered"): filtered_as = detail.get("filtered_as") or detail.get("suggested_category") print(f"[match] skip dish box filtered_as={filtered_as}") continue if detail.get("is_unknown"): unknown_count += 1 match_scope = detail.get("match_scope") in_menu = False if not detail.get("is_unknown"): in_menu = resolve_in_today_menu(detail.get("label"), match_scope) label_src = dict(detail) label_src["det_conf"] = det_conf label_src["in_today_menu"] = in_menu label_src["match_scope"] = match_scope idx = len(dishes) + 1 dish_labels.append(format_dish_box_label(label_src)) final_result += f"【菜品{idx}】{format_predict_detail(detail)}\n" kept_boxes.append(box) dishes.append({ "index": idx, "label": detail.get("label"), "score": detail.get("score"), "det_conf": det_conf, "is_unknown": bool(detail.get("is_unknown")), "suggested_category": detail.get("suggested_category"), "pending_id": detail.get("pending_id"), "text": format_predict_detail(detail), "box": box, "crop_image": img_path, "crop_array": crop_images[i] if i < len(crop_images) else None, "feature": detail.get("feature"), "match_scope": match_scope, "in_today_menu": in_menu, }) except Exception as e: idx = len(dishes) + 1 dish_labels.append("识别失败") final_result += f"【菜品{idx}】加载/识别失败:{e}\n" kept_boxes.append(box) dishes.append({ "index": idx, "label": None, "score": None, "det_conf": det_conf, "is_unknown": True, "suggested_category": None, "pending_id": None, "text": f"识别失败:{e}", "box": box, "crop_image": img_path, "crop_array": crop_images[i] if i < len(crop_images) else None, "feature": None, "match_scope": None, "in_today_menu": False, }) try: viz_labels = dish_labels if dish_labels else [ (d.get("label") or d.get("suggested_category") or f"菜品{d.get('index') or i + 1}") for i, d in enumerate(dishes) ] viz_styles = [dish_overlay_style(d) for d in dishes] labeled_path = self.build_dish_labeled_image( source_image_path, kept_boxes, viz_labels, styles=viz_styles, ) except Exception: labeled_path = None return final_result.strip(), dish_labels, unknown_count, labeled_path, dishes