Dish_Recognition/core/pending_learning_ui.py

801 lines
34 KiB
Python
Raw 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.

# 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)}"
class PendingLearningUIMixin:
"""待学习库界面与操作。"""
# ---------- 通用小工具(不依赖宿主实现) ----------
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("<Configure>", 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("<Configure>", 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)
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("<Button-1>",
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):
"""待学习库:用下拉框选择已有菜名后学习(也可直接手输新菜名)。"""
try:
existing_names = [name for name, _cnt, _thumb in self.manager.get_template_list()]
except Exception:
existing_names = []
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)
tk.Label(pick, text="可从已有菜名中选择,也可以直接输入一个新菜名。",
bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"],
).pack(padx=20, anchor=tk.W)
initial = suggested if suggested and suggested in existing_names else ""
name_var = tk.StringVar(value=initial)
combo = ttk.Combobox(
pick, textvariable=name_var, values=existing_names,
width=34, style="Large.TEntry",
)
combo.pack(padx=20, pady=10, fill=tk.X)
combo.focus_set()
if not existing_names:
tk.Label(pick, text="当前模板库还没有菜名,输入的新名字会新建一个类别。",
bg=COLORS["bg"], fg=COLORS["warn"], font=FONTS["caption"],
wraplength=280, 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("<Return>", 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
try:
existing_names = [name for name, _cnt, _thumb in self.manager.get_template_list()]
except Exception:
existing_names = []
dialog = self._pl_dialog("新菜确认学习", 860, 620)
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)
tk.Label(
dialog,
text="识别置信度低于阈值。确认菜名后可立即学习;直接关闭则保留在待学习库,稍后处理。",
bg=COLORS["bg"], fg=COLORS["muted"], font=FONTS["small"], wraplength=780,
justify=tk.LEFT,
).pack(anchor=tk.W, padx=18)
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()
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)
name_var = tk.StringVar(value=suggested)
ttk.Combobox(
name_row, textvariable=name_var, values=existing_names, 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,
draw_labels=True,
):
"""对多个裁剪区域识别。
auto_pending:
True — 未知菜写入待学习库(选图/拍照)
False — 仅判定未知,不写待学习(实时扫描)
cropped_file_paths: 裁剪图路径列表(可与 crop_images 二选一/并用)
crop_images: 可选 BGR ndarray 列表,优先用于特征提取(免读盘)
draw_labels: False 时跳过 PIL 标注图(实时扫描用,避免反复 RGBA 叠图崩溃)
返回:
final_result, dish_labels, unknown_count, labeled_path, dishes
dishes: 供 API 上报的结构化列表(含 feature,便于复用)
"""
import sys
def _mark(msg):
fn = getattr(sys, "_dish_crash_mark", None)
if callable(fn):
try:
fn(msg)
except Exception:
pass
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)
_mark(f"batch: extract start n={n}")
features = self.manager.extract_features_batch(sources)
_mark("batch: extract done")
final_result = ""
dish_labels = []
dishes = []
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
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_unknown"):
unknown_count += 1
dish_labels.append(format_dish_box_label(detail))
final_result += f"【菜品{i + 1}】{format_predict_detail(detail)}\n"
dishes.append({
"index": i + 1,
"label": detail.get("label"),
"score": detail.get("score"),
"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"),
})
except Exception as e:
dish_labels.append("识别失败")
final_result += f"【菜品{i + 1}】加载/识别失败:{e}\n"
dishes.append({
"index": i + 1,
"label": None,
"score": None,
"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,
})
labeled_path = None
if draw_labels:
try:
_mark("batch: draw_labels start")
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)
]
labeled_path = self.build_dish_labeled_image(source_image_path, boxes, viz_labels)
_mark("batch: draw_labels done")
except Exception as e:
_mark(f"batch: draw_labels fail {e}")
labeled_path = None
return final_result.strip(), dish_labels, unknown_count, labeled_path, dishes