更新待学习库,和判断运动更新,

This commit is contained in:
wanghanbo 2026-10-12 12:26:03 +08:00
parent 3e09e771cf
commit 2bf6d2c47f
2 changed files with 283 additions and 60 deletions

View File

@ -131,8 +131,9 @@ class BaseDishApp(PendingLearningUIMixin):
self._live_stable_committed_sig = None
self._live_stable_snapshot = None # 稳定期间保留的 dishes,供写入
self._last_reported_sig = None # 实时上报去重:相同结果只报一次
self._last_detect_fp = None # 识别后锚点指纹(静止等待运动)
self._last_detect_fp = None # 识别后锚点指纹(静止等待运动;仅餐盘区域)
self._last_live_dishes = None # 上一轮识别结果(跳过检测时推进稳定计时)
self._last_motion_boxes = None # 上一轮餐盘框,运动判定只看菜品区域
self._frame_sim_threshold = 0.992 # 帧相似度阈值:判定静止 / 运动
# 运动状态机:动→静保持 still_hold 秒 → YOLO 一次 → 静待运动 → 再静…
self._live_still_hold_seconds = float(DISH_LIVE_STILL_HOLD_SECONDS)
@ -194,9 +195,10 @@ class BaseDishApp(PendingLearningUIMixin):
return None
def take_live_frame(self):
"""抓取实时帧;返回 (0, path, frame) 或 (-1, None, None)。
"""抓取实时帧(默认不落盘)。返回 (0, None, frame) 或 (-1, None, None)。
优先复用预览缓存帧,避免与预览争抢相机;磁盘路径供上报/待学习。
优先复用预览缓存帧,避免与预览争抢相机。
有订单时再调用 _save_order_image 写入 images/。
"""
frame = None
with self._live_lock:
@ -209,19 +211,25 @@ class BaseDishApp(PendingLearningUIMixin):
frame = self.grab_preview_frame()
if frame is None:
return -1, None, None
return 0, None, frame
def _save_order_image(self, frame_bgr):
"""有一单时落盘一张整帧到 images/;成功返回绝对路径,失败返回 None。"""
if frame_bgr is None:
return None
os.makedirs("images", exist_ok=True)
stamp = int(time.time() * 1000)
save_path = os.path.abspath(os.path.join("images", f"_live_frame_{stamp}.jpg"))
try:
if cv2.imwrite(save_path, frame):
self._cleanup_old_live_frames(keep=30)
return 0, save_path, frame
if cv2.imwrite(save_path, frame_bgr):
self._cleanup_old_live_frames(keep=3000000)
return save_path
except Exception as e:
print(f"保存实时帧失败: {e}")
return -1, None, None
print(f"保存订单帧失败: {e}")
return None
def _cleanup_old_live_frames(self, keep=30):
"""清理过期实时帧,防止 images/ 无限增长。"""
"""清理过期订单帧,防止 images/ 无限增长。"""
try:
folder = os.path.abspath("images")
files = []
@ -1850,17 +1858,26 @@ class BaseDishApp(PendingLearningUIMixin):
self._motion_still_since = None
self._yolo_trigger_pending = False
def _motion_fingerprint(self, frame_bgr):
"""只对菜品/餐盘区域提指纹,忽略旁边行人晃动。"""
return self._frame_fingerprint(
frame_bgr,
boxes=getattr(self, "_last_motion_boxes", None),
)
def _update_motion_gate(self, frame_bgr):
"""根据当前帧更新运动状态;从动到静保持 hold 秒时置位 YOLO 触发。
运动只看餐盘区域(有上次检测框用框,否则画面中央),避免旁人晃动误触发。
状态:
moving — 画面在动(或刚开扫)
settling — 刚停住,累计静止时间
wait_motion — 已识别过,静止中忽略,直到再出现运动
moving — 菜品区域在动(或刚开扫)
settling — 菜品刚停住,累计静止时间
wait_motion — 已识别过,菜品静止中忽略,直到菜品再出现运动
"""
if frame_bgr is None:
return False
fp = self._frame_fingerprint(frame_bgr)
fp = self._motion_fingerprint(frame_bgr)
if fp is None:
return False
@ -1876,7 +1893,7 @@ class BaseDishApp(PendingLearningUIMixin):
self._motion_settle_fp = None
self._motion_still_since = None
if sim is not None:
print(f"[live] 检测到运动 sim={sim:.4f},等待静止后识别")
print(f"[live] 菜品区域运动 sim={sim:.4f},等待静止后识别")
return False
if state == "moving":
@ -1906,7 +1923,7 @@ class BaseDishApp(PendingLearningUIMixin):
self._motion_settle_fp = None
self._motion_still_since = None
self._motion_prev_fp = fp
print(f"[live] 静止满 {hold:g}s,触发 YOLO 识别一次")
print(f"[live] 菜品静止满 {hold:g}s,触发 YOLO 识别一次")
return True
return False
@ -1918,6 +1935,7 @@ class BaseDishApp(PendingLearningUIMixin):
self._last_reported_sig = None
self._last_detect_fp = None
self._last_live_dishes = None
self._last_motion_boxes = None
self._reset_motion_gate(initial_state="moving")
job = getattr(self, "_live_job", None)
if job is not None:
@ -1988,6 +2006,7 @@ class BaseDishApp(PendingLearningUIMixin):
self._live_stable_committed_sig = None
self._last_detect_fp = None
self._last_live_dishes = None
self._last_motion_boxes = None
self._reset_motion_gate(initial_state="moving")
if hasattr(self, "camera_btn"):
try:
@ -2136,13 +2155,77 @@ class BaseDishApp(PendingLearningUIMixin):
)
return Image.alpha_composite(rgba, overlay).convert("RGB")
def _frame_fingerprint(self, frame_bgr):
"""小图灰度指纹,用于静止画面相似度判断(轻量、抗轻微噪声)。"""
def _normalize_motion_boxes(self, boxes, width, height):
"""把菜品框规范成像素矩形列表;无效则空列表。"""
out = []
for box in boxes or []:
try:
if len(box) < 4:
continue
x1, y1, x2, y2 = [int(round(float(v))) for v in box[:4]]
except Exception:
continue
x1 = max(0, min(width - 1, x1))
x2 = max(0, min(width, x2))
y1 = max(0, min(height - 1, y1))
y2 = max(0, min(height, y2))
if x2 - x1 < 8 or y2 - y1 < 8:
continue
out.append((x1, y1, x2, y2))
return out
def _motion_roi_boxes(self, frame_bgr, boxes=None):
"""运动判定 ROI:优先上次/传入餐盘框(略扩张);否则用画面中央放盘区。"""
if frame_bgr is None:
return []
h, w = frame_bgr.shape[:2]
rois = self._normalize_motion_boxes(boxes, w, h)
if not rois:
# 中央约 60% 区域,避开两侧行人
mw, mh = int(w * 0.6), int(h * 0.6)
x1 = max(0, (w - mw) // 2)
y1 = max(0, (h - mh) // 2)
return [(x1, y1, x1 + mw, y1 + mh)]
pad_x = max(8, int(w * 0.03))
pad_y = max(8, int(h * 0.03))
expanded = []
for x1, y1, x2, y2 in rois:
expanded.append((
max(0, x1 - pad_x),
max(0, y1 - pad_y),
min(w, x2 + pad_x),
min(h, y2 + pad_y),
))
return expanded
def _frame_fingerprint(self, frame_bgr, boxes=None):
"""小图灰度指纹,用于静止/运动判断(默认只看餐盘/中央 ROI,抗旁人晃动)。"""
if frame_bgr is None:
return None
try:
gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
small = cv2.resize(gray, (64, 64), interpolation=cv2.INTER_AREA)
h, w = gray.shape[:2]
rois = self._motion_roi_boxes(frame_bgr, boxes)
if len(rois) == 1:
x1, y1, x2, y2 = rois[0]
region = gray[y1:y2, x1:x2]
else:
# 多盘:拼成横向条带,避免整帧边缘干扰
crops = [gray[y1:y2, x1:x2] for x1, y1, x2, y2 in rois]
target_h = 64
resized = []
for crop in crops:
if crop.size == 0:
continue
ch, cw = crop.shape[:2]
nw = max(8, int(round(cw * (target_h / float(ch)))))
resized.append(cv2.resize(crop, (nw, target_h), interpolation=cv2.INTER_AREA))
if not resized:
return None
region = np.hstack(resized)
if region is None or region.size == 0:
return None
small = cv2.resize(region, (64, 64), interpolation=cv2.INTER_AREA)
small = cv2.GaussianBlur(small, (3, 3), 0)
v = small.astype(np.float32).ravel()
v -= float(v.mean())
@ -2271,9 +2354,9 @@ class BaseDishApp(PendingLearningUIMixin):
self.root.after(0, _moving_tip)
return
fp = self._frame_fingerprint(frame)
stem = Path(save_path).stem if save_path else f"_live_{int(time.time() * 1000)}"
stem = f"_live_frame_{int(time.time() * 1000)}"
# 内存直传 YOLO;裁剪落盘供购物车缩略图 / 待学习;跳过中间 Plate 可视化
# 整帧 images/ 仅在「有一单」时由 _save_order_image 写入
self.root.after(0, lambda: self._set_result_text("扫描中:YOLO 检测餐盘…", "busy"))
visualize_save_path, crop_items, boxes = self.app.process_image(
frame,
@ -2284,23 +2367,25 @@ class BaseDishApp(PendingLearningUIMixin):
cropped_file_paths = [c.get("path") for c in crop_items]
crop_arrays = [c.get("image") for c in crop_items]
# 识别完成后进入 wait_motion:持续静止不再识别,须再动→静
self._last_detect_fp = fp
# 识别完成后进入 wait_motion:只盯餐盘区域,旁人晃动不触发
self._last_motion_boxes = list(boxes or []) if crop_items else None
self._last_detect_fp = self._motion_fingerprint(frame)
self._motion_state = "wait_motion"
self._yolo_trigger_pending = False
if not crop_items:
self._last_live_dishes = []
self._last_motion_boxes = None
self._reset_live_stability()
def _no_plate():
self._live_overlay_dishes = []
self.render_result_dock([], None)
self._set_result_text("扫描中:未检测到餐盘", "warn")
# 未检出新菜:左侧已点清单保留上次结果,不清空
self._set_result_text("扫描中:未检测到餐盘(已点清单保持上次)", "warn")
if hasattr(self, "scan_tip_label"):
try:
self.scan_tip_label.config(
text="扫描中 · 未检出餐盘 · 静止中等待下一次运动"
text="扫描中 · 未检出餐盘 · 左侧保持上次已点 · 等待下一次运动"
)
except Exception:
pass
@ -2315,7 +2400,7 @@ class BaseDishApp(PendingLearningUIMixin):
),
)
final_result, _, unknown_count, labeled_path, dishes = self.run_plate_predict_batch(
frame if frame is not None else save_path,
frame,
cropped_file_paths,
boxes,
auto_pending=False,
@ -2323,16 +2408,26 @@ class BaseDishApp(PendingLearningUIMixin):
det_confs=[c.get("conf") for c in crop_items],
)
self._last_live_dishes = list(dishes or [])
preview = labeled_path or visualize_save_path or save_path
self._last_motion_boxes = [
d.get("box") for d in (dishes or []) if d.get("box")
] or list(boxes or [])
# 用识别后的餐盘框再锚一次,后续 wait_motion 更稳
self._last_detect_fp = self._motion_fingerprint(frame)
known_dishes = [d for d in dishes if not d.get("is_unknown")]
# 实时:仅有已识别菜才上报;相同结果只报一次;排餐比对不刷 pending
if known_dishes and save_path:
self.report_recognition(
save_path, known_dishes, preview,
schedule_check=False, dedupe=True,
)
# 有一单(至少 1 个已识别菜,且与上次不同)才存 images/ 并上报;无单不落盘
save_path = None
if known_dishes:
sig = self._report_signature(known_dishes)
if sig and sig != self._last_reported_sig:
save_path = self._save_order_image(frame)
if save_path:
self.report_recognition(
save_path, known_dishes, labeled_path or visualize_save_path or save_path,
schedule_check=False, dedupe=True,
)
else:
self._last_reported_sig = None
preview = labeled_path or visualize_save_path or save_path
# 同一结果稳定满 hold 秒 → 未知菜写入待学习(同场景一次;盘走不刷入)
commit_payload = self._update_live_stability(dishes)
@ -2376,8 +2471,10 @@ class BaseDishApp(PendingLearningUIMixin):
msg += f"|未知 {len(unknown)}(不入清单)"
self._set_result_text(msg, "ok" if not unknown else "warn")
else:
keep_hint = "|左侧保持上次已点" if self._cart else ""
self._set_result_text(
txt or f"扫描中|检出 {len(ds)} 盘,尚无达到阈值的菜品",
(txt or f"扫描中|检出 {len(ds)} 盘,尚无达到阈值的菜品")
+ keep_hint,
"warn",
)
@ -2439,7 +2536,10 @@ class BaseDishApp(PendingLearningUIMixin):
self._set_result_text("已清空本次已点菜品", "ok")
def fill_cart_from_dishes(self, dishes):
"""左侧清单只放已识别菜;未知仅留在预览标注 / 待学习流程。"""
"""左侧清单只放已识别菜;未知仅留在预览标注 / 待学习流程。
若本次没有新的已识别菜,则保留上次清单不刷新。
"""
cart = []
for d in dishes or []:
if d.get("is_unknown"):
@ -2456,8 +2556,13 @@ class BaseDishApp(PendingLearningUIMixin):
"pending_id": d.get("pending_id"),
"suggested_category": d.get("suggested_category"),
})
self._cart = cart
self._last_dishes = dishes or []
if cart:
self._cart = cart
self._last_dishes = dishes or []
elif not self._cart:
# 本来就是空清单,同步记录本次结果
self._last_dishes = dishes or []
# else: 本次无新已识别菜 → 左侧继续展示上次已点菜品
def _change_qty(self, idx, delta):
if idx < 0 or idx >= len(self._cart):

View File

@ -117,10 +117,113 @@ def resolve_in_today_menu(dish_name, match_scope=None):
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
class PendingLearningUIMixin:
"""待学习库界面与操作。"""
# ---------- 通用小工具(不依赖宿主实现) ----------
def _pl_learn_name_choices(self, suggested=""):
"""学习下拉候选:仅当前餐段菜品(不在餐段时用今日菜单)。"""
period_name, period_names, is_period = _period_menu_choices()
values = list(period_names)
suggested = (suggested or "").strip()
if suggested and suggested in values:
initial = suggested
else:
initial = ""
return period_name, period_names, values, initial, is_period
def _build_today_menu_section(self, parent):
"""待学习库顶部:展示今日菜单,高亮当前餐段。"""
try:
from dao.meal_schedule import get_meal_schedule_store
info = get_meal_schedule_store().periods_for_display()
except Exception as exc:
info = {"error": str(exc), "periods": [], "current_period": None}
card = RoundCard(parent, title="今日菜单",
subtitle="学习时可从当前餐段菜品下拉选择",
radius=RADIUS["md"], padding=10)
card.pack(fill=tk.X, padx=14, pady=(8, 4))
body = card.body
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=1000, 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=1000, justify=tk.LEFT,
anchor="w").pack(fill=tk.X, pady=(4, 0))
def _pl_toast(self, text, tone="info"):
if hasattr(self, "_toast"):
try:
@ -253,7 +356,7 @@ class PendingLearningUIMixin:
pass
self._pending_dialog_open = True
dialog = self._pl_dialog("待学习菜品库", 1120, 720)
dialog = self._pl_dialog("待学习菜品库", 1120, 820)
def _close_pending():
self._pending_dialog_open = False
@ -289,6 +392,8 @@ class PendingLearningUIMixin:
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))
@ -444,11 +549,8 @@ class PendingLearningUIMixin:
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 = []
"""待学习库:下拉仅选当前餐段菜品。"""
period_name, period_names, values, initial, is_period = self._pl_learn_name_choices(suggested)
pick = tk.Toplevel(dialog)
pick.title("选择菜品名称")
@ -459,23 +561,31 @@ class PendingLearningUIMixin:
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)
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)
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,
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 existing_names:
tk.Label(pick, text="当前模板库还没有菜名,输入的新名字会新建一个类别。",
if not values:
tk.Label(pick, text="暂无可选菜名,请直接输入新菜名(将新建类别)。",
bg=COLORS["bg"], fg=COLORS["warn"], font=FONTS["caption"],
wraplength=280, justify=tk.LEFT).pack(padx=20, anchor=tk.W)
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))
@ -483,7 +593,7 @@ class PendingLearningUIMixin:
def _confirm():
category = (name_var.get() or "").strip()
if not category:
messagebox.showwarning("提示", "请先输入或选择菜品名称", parent=pick)
messagebox.showwarning("提示", "请先选择或输入菜品名称", parent=pick)
return
ok = self.manager.learn_from_pending(pending_id, category)
if ok:
@ -544,12 +654,9 @@ class PendingLearningUIMixin:
on_finished(False)
return
try:
existing_names = [name for name, _cnt, _thumb in self.manager.get_template_list()]
except Exception:
existing_names = []
period_name, period_names, learn_values, _, is_period = self._pl_learn_name_choices("")
dialog = self._pl_dialog("新菜确认学习", 860, 620)
dialog = self._pl_dialog("新菜确认学习", 860, 680)
learned_count = {"n": 0}
thumb_refs = []
@ -561,12 +668,22 @@ class PendingLearningUIMixin:
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="识别置信度低于阈值。确认菜名后可立即学习;直接关闭则保留在待学习库,稍后处理。",
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)
@ -650,9 +767,10 @@ class PendingLearningUIMixin:
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)
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=existing_names, width=26,
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"],