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