diff --git a/exp_multimodal/exp_multimodal_gui.py b/exp_multimodal/exp_multimodal_gui.py index 2824c54..3ff8ae5 100644 --- a/exp_multimodal/exp_multimodal_gui.py +++ b/exp_multimodal/exp_multimodal_gui.py @@ -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 以支持拖拽