允许添加自定义菜品,以及导入json文件。
This commit is contained in:
@@ -77,11 +77,22 @@ 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):
|
||||
@@ -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 以支持拖拽
|
||||
|
||||
Reference in New Issue
Block a user