允许添加自定义菜品,以及导入json文件。

This commit is contained in:
2025-11-13 14:12:32 +08:00
parent 5cfb84084a
commit 9845d0b05f
+342 -6
View File
@@ -77,12 +77,23 @@ class MultiModalFoodApp:
self.openset_embedder: Optional[OllamaEmbedder] = None
self.openset_matcher: Optional[DishNameMatcher] = None
# 自定义菜品库管理
self.custom_dishes_path = "exp_multimodal/custom_dish_library.json"
self.custom_dishes: List[str] = []
self.auto_rebuild_index_var = ctk.BooleanVar(value=True)
# UI
self.create_widgets()
# 加载自定义菜品库
self.load_custom_dishes()
# 加载保存的配置
self.load_config()
# 初始化菜品计数显示
self.root.after(100, self.update_dish_count_label)
# -------------------- 图像加载工具 --------------------
def load_image_with_chinese_path(self, file_path: str):
try:
@@ -522,6 +533,37 @@ class MultiModalFoodApp:
self.openset_status_label = ctk.CTkLabel(openset_row4, text="", font=("Arial", 10), text_color="gray")
self.openset_status_label.pack(side="left", padx=12)
# ==================== 菜品库管理 ====================
dish_lib_frame = ctk.CTkFrame(panel)
dish_lib_frame.pack(fill="x", pady=(10, 10))
ctk.CTkLabel(dish_lib_frame, text="菜品库管理(开放式识别)", font=("Arial", 14, "bold")).pack(anchor="w", pady=(0, 6))
# 当前菜品数
dish_count_row = ctk.CTkFrame(dish_lib_frame)
dish_count_row.pack(fill="x", pady=(0, 6))
self.dish_count_label = ctk.CTkLabel(dish_count_row, text="当前菜品数:0(默认) + 0(自定义)", font=("Arial", 11))
self.dish_count_label.pack(side="left", padx=6, pady=4)
# 单个添加
add_row = ctk.CTkFrame(dish_lib_frame)
add_row.pack(fill="x", pady=(0, 6))
self.new_dish_var = ctk.StringVar(value="")
ctk.CTkEntry(add_row, textvariable=self.new_dish_var, placeholder_text="输入菜品名称", width=280).pack(side="left", padx=6)
ctk.CTkButton(add_row, text=" 添加", command=self.add_single_dish, width=80).pack(side="left", padx=6)
# 批量导入 + 查看库
manage_row = ctk.CTkFrame(dish_lib_frame)
manage_row.pack(fill="x", pady=(0, 6))
ctk.CTkButton(manage_row, text="📁 批量导入JSON", command=self.import_dishes_from_json, width=140).pack(side="left", padx=6)
ctk.CTkButton(manage_row, text="📋 查看菜品库", command=self.show_dish_library_dialog, width=140).pack(side="left", padx=6)
ctk.CTkButton(manage_row, text="🗑️ 清空自定义", command=self.clear_custom_dishes, width=120, fg_color="red", hover_color="darkred").pack(side="left", padx=6)
# 自动重建索引开关
auto_row = ctk.CTkFrame(dish_lib_frame)
auto_row.pack(fill="x", pady=(4, 0))
ctk.CTkSwitch(auto_row, text="添加菜品后自动重建索引", variable=self.auto_rebuild_index_var).pack(side="left", padx=6, pady=4)
# 配置保存/加载
cfg_manage = ctk.CTkFrame(panel)
cfg_manage.pack(fill="x", pady=(10, 10))
@@ -719,29 +761,36 @@ class MultiModalFoodApp:
"""索引构建后台线程"""
try:
mode = "dish" # 开放式识别目前仅支持 dish
labels = build_labels(mode, alias_map_path=None)
if not labels:
# 合并默认菜品 + 自定义菜品
default_labels = build_labels(mode, alias_map_path=None)
# 合并自定义菜品(去重)
all_labels = list(dict.fromkeys(list(default_labels) + list(self.custom_dishes)))
if not all_labels:
self.root.after(0, lambda: messagebox.showerror("错误", "未找到可用的菜品名"))
self.root.after(0, lambda: self.openset_status_label.configure(text="构建失败", text_color="red"))
return
print(f"[BuildIndex] Total dishes: {len(all_labels)} (default={len(default_labels)}, custom={len(self.custom_dishes)})")
# 初始化 Embedder
embedder_url = self.ollama_url_var.get().strip() or DEFAULT_OLLAMA_URL
embedder_model = self.openset_embedder_model_var.get().strip()
embedder = OllamaEmbedder(base_url=embedder_url, model=embedder_model)
# 构建索引
# 构建索引(使用合并后的菜品列表)
output_dir = self.openset_index_path_var.get()
build_dish_index(
dish_names=labels,
dish_names=all_labels,
embedder=embedder,
output_dir=output_dir,
batch_size=100
)
self.root.after(0, lambda: messagebox.showinfo("成功", f"索引构建完成!位置: {output_dir}"))
self.root.after(0, lambda: self.openset_status_label.configure(text=f"索引已构建 ({len(labels)}个菜品)", text_color="green"))
self.root.after(0, lambda: messagebox.showinfo("成功", f"索引构建完成!默认菜品:{len(default_labels)}个,自定义菜品:{len(self.custom_dishes)}个,总计:{len(all_labels)}"))
self.root.after(0, lambda: self.openset_status_label.configure(text=f"索引已构建 ({len(all_labels)}个菜品)", text_color="green"))
except Exception as e:
self.root.after(0, lambda: messagebox.showerror("错误", f"构建索引失败: {e}"))
@@ -1030,6 +1079,9 @@ class MultiModalFoodApp:
"min_score": self.openset_min_score_var.get(),
"embedder_model": self.openset_embedder_model_var.get(),
},
# 自定义菜品库配置
"custom_dishes_path": self.custom_dishes_path,
"auto_rebuild_index": self.auto_rebuild_index_var.get(),
}
try:
@@ -1090,10 +1142,21 @@ class MultiModalFoodApp:
self.openset_min_score_var.set(openset_cfg.get("min_score", 0.5))
self.openset_embedder_model_var.set(openset_cfg.get("embedder_model", "quentinz/bge-large-zh-v1.5"))
# 自定义菜品库配置
if "custom_dishes_path" in config:
self.custom_dishes_path = config["custom_dishes_path"]
self.load_custom_dishes()
if "auto_rebuild_index" in config:
self.auto_rebuild_index_var.set(config["auto_rebuild_index"])
self.refresh_extra_labels_view()
self.refresh_fewshot_view()
self.on_provider_change()
# 更新菜品计数显示
self.update_dish_count_label()
print(f"[Config] 配置已从 {config_path} 加载")
except Exception as e:
print(f"[Config] 加载配置失败: {e}")
@@ -1151,14 +1214,287 @@ class MultiModalFoodApp:
self.openset_min_score_var.set(openset_cfg.get("min_score", 0.5))
self.openset_embedder_model_var.set(openset_cfg.get("embedder_model", "quentinz/bge-large-zh-v1.5"))
# 自定义菜品库配置
if "custom_dishes_path" in config:
self.custom_dishes_path = config["custom_dishes_path"]
self.load_custom_dishes()
if "auto_rebuild_index" in config:
self.auto_rebuild_index_var.set(config["auto_rebuild_index"])
self.refresh_extra_labels_view()
self.refresh_fewshot_view()
self.on_provider_change()
# 更新菜品计数显示
self.update_dish_count_label()
messagebox.showinfo("成功", f"配置已从 {os.path.basename(path)} 加载")
except Exception as e:
messagebox.showerror("错误", f"加载配置失败: {e}")
# -------------------- 自定义菜品库管理 --------------------
def load_custom_dishes(self):
"""从JSON加载自定义菜品库"""
if not os.path.exists(self.custom_dishes_path):
self.custom_dishes = []
return
try:
with open(self.custom_dishes_path, "r", encoding="utf-8") as f:
data = json.load(f)
# 支持两种格式:数组 或 {"dishes": [...]}
if isinstance(data, list):
self.custom_dishes = [str(x).strip() for x in data if str(x).strip()]
elif isinstance(data, dict) and "dishes" in data:
self.custom_dishes = [str(x).strip() for x in data["dishes"] if str(x).strip()]
else:
self.custom_dishes = []
print(f"[CustomDishes] Loaded {len(self.custom_dishes)} dishes from {self.custom_dishes_path}")
except Exception as e:
print(f"[CustomDishes] Failed to load: {e}")
self.custom_dishes = []
def save_custom_dishes(self):
"""保存自定义菜品库到JSON"""
try:
os.makedirs(os.path.dirname(self.custom_dishes_path) if os.path.dirname(self.custom_dishes_path) else ".", exist_ok=True)
data = {
"dishes": self.custom_dishes,
"metadata": {
"total_count": len(self.custom_dishes),
"last_updated": time.strftime("%Y-%m-%d %H:%M:%S")
}
}
with open(self.custom_dishes_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"[CustomDishes] Saved {len(self.custom_dishes)} dishes to {self.custom_dishes_path}")
except Exception as e:
print(f"[CustomDishes] Failed to save: {e}")
messagebox.showerror("错误", f"保存菜品库失败: {e}")
def add_single_dish(self):
"""添加单个菜品"""
dish_name = self.new_dish_var.get().strip()
if not dish_name:
messagebox.showwarning("提示", "请输入菜品名称")
return
# 去重
if dish_name in self.custom_dishes:
messagebox.showinfo("提示", f"菜品'{dish_name}'已存在")
return
self.custom_dishes.append(dish_name)
self.save_custom_dishes()
self.new_dish_var.set("")
self.update_dish_count_label()
messagebox.showinfo("成功", f"已添加菜品:{dish_name}")
# 询问是否立即重建索引
if self.auto_rebuild_index_var.get():
if messagebox.askyesno("重建索引", "是否立即重建向量索引?"):
self.build_openset_index()
def import_dishes_from_json(self):
"""从JSON文件批量导入菜品"""
path = filedialog.askopenfilename(
title="选择菜品JSON文件",
filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")]
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
# 解析JSON(支持简单数组格式)
if isinstance(data, list):
new_dishes = [str(x).strip() for x in data if str(x).strip()]
elif isinstance(data, dict) and "dishes" in data:
new_dishes = [str(x).strip() for x in data["dishes"] if str(x).strip()]
else:
messagebox.showerror("错误", "JSON格式不正确,应为数组 ['菜品1', '菜品2'] 或 {\"dishes\": [...]}")
return
if not new_dishes:
messagebox.showwarning("提示", "JSON文件中没有有效的菜品名")
return
# 去重并合并
original_count = len(self.custom_dishes)
existing_set = set(self.custom_dishes)
added_count = 0
for dish in new_dishes:
if dish not in existing_set:
self.custom_dishes.append(dish)
existing_set.add(dish)
added_count += 1
self.save_custom_dishes()
self.update_dish_count_label()
messagebox.showinfo(
"导入完成",
f"成功导入 {added_count} 个新菜品" f"(总计导入{len(new_dishes)}个,其中{len(new_dishes)-added_count}个已存在)" + f"当前自定义菜品数:{len(self.custom_dishes)}"
)
# 询问是否重建索引
if self.auto_rebuild_index_var.get() and added_count > 0:
if messagebox.askyesno("重建索引", f"已添加{added_count}个新菜品,是否立即重建向量索引?"):
self.build_openset_index()
except Exception as e:
messagebox.showerror("错误", f"导入失败: {e}")
def show_dish_library_dialog(self):
"""显示菜品库查看/管理对话框"""
dialog = ctk.CTkToplevel(self.root)
dialog.title("菜品库管理")
dialog.geometry("700x600")
dialog.transient(self.root)
dialog.grab_set()
# 标题
title_frame = ctk.CTkFrame(dialog)
title_frame.pack(fill="x", padx=15, pady=(15, 10))
default_count = len(build_labels("dish", self.alias_map_path))
custom_count = len(self.custom_dishes)
ctk.CTkLabel(
title_frame,
text=f"📚 当前菜品库",
font=("Arial", 16, "bold")
).pack(side="left", padx=6)
ctk.CTkLabel(
title_frame,
text=f"默认:{default_count}个 | 自定义:{custom_count}个 | 总计:{default_count + custom_count}",
font=("Arial", 11)
).pack(side="right", padx=6)
# 分栏显示
notebook = ctk.CTkTabview(dialog)
notebook.pack(fill="both", expand=True, padx=15, pady=(0, 15))
# 自定义菜品选项卡
custom_tab = notebook.add("自定义菜品")
self._create_dish_list_view(custom_tab, self.custom_dishes, is_custom=True)
# 默认菜品选项卡
default_tab = notebook.add("默认菜品")
default_dishes = build_labels("dish", self.alias_map_path)
self._create_dish_list_view(default_tab, default_dishes, is_custom=False)
# 设置默认选中自定义选项卡
notebook.set("自定义菜品")
def _create_dish_list_view(self, parent, dishes: List[str], is_custom: bool):
"""创建菜品列表视图"""
# 搜索框
search_frame = ctk.CTkFrame(parent)
search_frame.pack(fill="x", padx=10, pady=(10, 10))
search_var = ctk.StringVar()
ctk.CTkLabel(search_frame, text="🔍 搜索:").pack(side="left", padx=6)
search_entry = ctk.CTkEntry(search_frame, textvariable=search_var, width=300)
search_entry.pack(side="left", padx=6)
# 列表框
list_frame = ctk.CTkScrollableFrame(parent)
list_frame.pack(fill="both", expand=True, padx=10, pady=(0, 10))
# 存储列表项引用
list_items = []
def refresh_list():
# 清空
for item in list_items:
item.destroy()
list_items.clear()
# 过滤
keyword = search_var.get().strip().lower()
filtered = [d for d in dishes if keyword in d.lower()] if keyword else dishes
# 显示
for dish in filtered:
row = ctk.CTkFrame(list_frame)
row.pack(fill="x", padx=5, pady=2)
ctk.CTkLabel(row, text=dish, anchor="w", font=("Arial", 11)).pack(side="left", fill="x", expand=True, padx=10, pady=5)
if is_custom:
def delete_dish(d=dish):
if messagebox.askyesno("确认删除", f"确定删除菜品'{d}'吗?"):
self.custom_dishes.remove(d)
self.save_custom_dishes()
self.update_dish_count_label()
refresh_list()
ctk.CTkButton(
row,
text="删除",
command=delete_dish,
width=60,
height=28,
fg_color="red",
hover_color="darkred"
).pack(side="right", padx=5, pady=2)
list_items.append(row)
# 显示计数
if not filtered and keyword:
no_result = ctk.CTkLabel(list_frame, text="未找到匹配的菜品", text_color="gray")
no_result.pack(pady=20)
list_items.append(no_result)
# 搜索触发
search_var.trace_add("write", lambda *args: refresh_list())
# 初始化显示
refresh_list()
# 底部操作按钮
if is_custom:
bottom_frame = ctk.CTkFrame(parent)
bottom_frame.pack(fill="x", padx=10, pady=(0, 10))
ctk.CTkLabel(bottom_frame, text=f"{len(dishes)} 个自定义菜品", font=("Arial", 10)).pack(side="left", padx=10)
def clear_custom_dishes(self):
"""清空自定义菜品库"""
if not self.custom_dishes:
messagebox.showinfo("提示", "自定义菜品库已经为空")
return
if messagebox.askyesno("确认清空", f"确定清空所有 {len(self.custom_dishes)} 个自定义菜品吗?此操作不可恢复!"):
self.custom_dishes.clear()
self.save_custom_dishes()
self.update_dish_count_label()
messagebox.showinfo("成功", "已清空自定义菜品库")
def update_dish_count_label(self):
"""更新菜品数量显示"""
if hasattr(self, 'dish_count_label'):
default_count = len(build_labels("dish", self.alias_map_path))
custom_count = len(self.custom_dishes)
total_count = default_count + custom_count
self.dish_count_label.configure(
text=f"当前菜品数:{default_count}(默认) + {custom_count}(自定义) = {total_count}(总计)"
)
def main():
root = TkinterDnD.Tk() # 必须使用 TkinterDnD.Tk 以支持拖拽