增加Kimi多模态Api,这样可以灵活选择!

This commit is contained in:
2025-11-10 11:41:23 +08:00
parent 34135bbb75
commit f204799b3e
9 changed files with 11720 additions and 20 deletions
+255 -16
View File
@@ -15,7 +15,7 @@ from tkinterdnd2 import DND_FILES, TkinterDnD
from exp_multimodal.labels import build_labels, _normalize, _base_ingredient
from exp_multimodal.vlm_classifier import classify_image
from exp_multimodal.ollama_client import OLLAMA_URL as DEFAULT_OLLAMA_URL, DEFAULT_MODEL as DEFAULT_VLM_MODEL
from exp_multimodal import ollama_client as _ollama_mod
from exp_multimodal.vlm_providers import VLMProvider, OllamaProvider, KimiProvider
ctk.set_appearance_mode("System")
@@ -38,8 +38,15 @@ class MultiModalFoodApp:
# 配置
self.mode_var = ctk.StringVar(value="dish") # dish | whole | processed
# VLM Provider 配置
self.provider_var = ctk.StringVar(value="ollama") # ollama | kimi
self.ollama_url_var = ctk.StringVar(value=DEFAULT_OLLAMA_URL)
self.vlm_model_var = ctk.StringVar(value=DEFAULT_VLM_MODEL)
self.kimi_api_key_var = ctk.StringVar(value="")
self.kimi_base_url_var = ctk.StringVar(value="https://api.moonshot.cn/v1")
self.kimi_model_var = ctk.StringVar(value="moonshot-v1-32k-vision-preview")
self.alias_map_path: Optional[str] = None
# Fewshot 可视化编辑(C 方案): dict[label] = hint
@@ -58,6 +65,9 @@ class MultiModalFoodApp:
# UI
self.create_widgets()
# 加载保存的配置
self.load_config()
# -------------------- 图像加载工具 --------------------
def load_image_with_chinese_path(self, file_path: str):
@@ -323,14 +333,58 @@ class MultiModalFoodApp:
rb = ctk.CTkRadioButton(mode_frame, text=text, variable=self.mode_var, value=val)
rb.pack(side="left", padx=8, pady=8)
# 服务配置
ctk.CTkLabel(panel, text="VLM 服务配置", font=("Arial", 14, "bold")).pack(anchor="w", pady=(10, 6))
svc = ctk.CTkFrame(panel)
svc.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(svc, text="OLLAMA_URL:").pack(side="left", padx=6, pady=8)
ctk.CTkEntry(svc, textvariable=self.ollama_url_var, width=320).pack(side="left", padx=4, pady=8)
ctk.CTkLabel(svc, text="VLM_MODEL:").pack(side="left", padx=12, pady=8)
ctk.CTkEntry(svc, textvariable=self.vlm_model_var, width=220).pack(side="left", padx=4, pady=8)
# VLM 服务提供商选择
ctk.CTkLabel(panel, text="VLM 服务提供商", font=("Arial", 14, "bold")).pack(anchor="w", pady=(10, 6))
provider_frame = ctk.CTkFrame(panel)
provider_frame.pack(fill="x", pady=(0, 10))
for val, text in [("ollama", "Ollama (本地/自建)"), ("kimi", "Kimi 1.5 (厂商API)")]:
rb = ctk.CTkRadioButton(
provider_frame,
text=text,
variable=self.provider_var,
value=val,
command=self.on_provider_change
)
rb.pack(side="left", padx=8, pady=8)
# Ollama 配置区域
self.ollama_config_frame = ctk.CTkFrame(panel)
self.ollama_config_frame.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(self.ollama_config_frame, text="Ollama 配置", font=("Arial", 12, "bold")).pack(anchor="w", pady=(4, 4))
ollama_row1 = ctk.CTkFrame(self.ollama_config_frame)
ollama_row1.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(ollama_row1, text="OLLAMA_URL:").pack(side="left", padx=6, pady=4)
ctk.CTkEntry(ollama_row1, textvariable=self.ollama_url_var, width=420).pack(side="left", padx=4, pady=4)
ollama_row2 = ctk.CTkFrame(self.ollama_config_frame)
ollama_row2.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(ollama_row2, text="MODEL:").pack(side="left", padx=6, pady=4)
ctk.CTkEntry(ollama_row2, textvariable=self.vlm_model_var, width=420).pack(side="left", padx=4, pady=4)
# Kimi 配置区域
self.kimi_config_frame = ctk.CTkFrame(panel)
self.kimi_config_frame.pack(fill="x", pady=(0, 10))
ctk.CTkLabel(self.kimi_config_frame, text="Kimi 配置", font=("Arial", 12, "bold")).pack(anchor="w", pady=(4, 4))
kimi_row1 = ctk.CTkFrame(self.kimi_config_frame)
kimi_row1.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(kimi_row1, text="API_KEY:").pack(side="left", padx=6, pady=4)
ctk.CTkEntry(kimi_row1, textvariable=self.kimi_api_key_var, width=420, show="*").pack(side="left", padx=4, pady=4)
kimi_row2 = ctk.CTkFrame(self.kimi_config_frame)
kimi_row2.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(kimi_row2, text="BASE_URL:").pack(side="left", padx=6, pady=4)
ctk.CTkEntry(kimi_row2, textvariable=self.kimi_base_url_var, width=420).pack(side="left", padx=4, pady=4)
kimi_row3 = ctk.CTkFrame(self.kimi_config_frame)
kimi_row3.pack(fill="x", pady=(0, 4))
ctk.CTkLabel(kimi_row3, text="MODEL:").pack(side="left", padx=6, pady=4)
ctk.CTkEntry(kimi_row3, textvariable=self.kimi_model_var, width=420).pack(side="left", padx=4, pady=4)
# 初始化显示状态
self.on_provider_change()
# Alias Map
alias = ctk.CTkFrame(panel)
@@ -385,6 +439,16 @@ class MultiModalFoodApp:
self.refresh_extra_labels_view()
self.refresh_fewshot_view()
# 配置保存/加载
cfg_manage = ctk.CTkFrame(panel)
cfg_manage.pack(fill="x", pady=(10, 10))
ctk.CTkLabel(cfg_manage, text="配置管理", font=("Arial", 14, "bold")).pack(anchor="w", pady=(0, 6))
cfg_row = ctk.CTkFrame(cfg_manage)
cfg_row.pack(fill="x")
ctk.CTkButton(cfg_row, text="保存当前配置", command=self.save_config, width=140, fg_color="blue", hover_color="darkblue").pack(side="left", padx=6)
ctk.CTkButton(cfg_row, text="加载配置", command=self.load_config_from_file, width=140).pack(side="left", padx=6)
ctk.CTkLabel(cfg_row, text="(自动保存到 config.json)", font=("Arial", 10), text_color="gray").pack(side="left", padx=12)
def pick_alias_file(self):
path = filedialog.askopenfilename(title="选择 alias_map.json", filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")])
if path:
@@ -395,6 +459,16 @@ class MultiModalFoodApp:
self.alias_map_path = None
self.alias_label_var.set("未选择")
def on_provider_change(self):
"""切换 Provider 时显示/隐藏对应的配置区域"""
provider = self.provider_var.get()
if provider == "ollama":
self.ollama_config_frame.pack(fill="x", pady=(0, 10), before=self.kimi_config_frame)
self.kimi_config_frame.pack_forget()
elif provider == "kimi":
self.kimi_config_frame.pack(fill="x", pady=(0, 10), before=self.ollama_config_frame)
self.ollama_config_frame.pack_forget()
# 额外标签编辑
def refresh_extra_labels_view(self):
self.extra_labels_listbox.configure(state="normal")
@@ -562,12 +636,13 @@ class MultiModalFoodApp:
labels = self._build_final_labels(mode)
ingredient_only = (mode in {"whole", "processed"})
use_hints = self.fewshot_hints if self.fewshot_enabled_var.get() else None
# 环境覆盖(仅对运行时生效,不修改模块常量)
os.environ["OLLAMA_URL"] = self.ollama_url_var.get().strip() or DEFAULT_OLLAMA_URL
os.environ["VLM_MODEL"] = self.vlm_model_var.get().strip() or DEFAULT_VLM_MODEL
# 同时覆盖已导入模块中的运行时常量,确保 classify 调用使用最新配置
_ollama_mod.OLLAMA_URL = os.environ["OLLAMA_URL"]
_ollama_mod.DEFAULT_MODEL = os.environ["VLM_MODEL"]
# 创建 VLM Provider
provider = self._create_vlm_provider()
if provider is None:
self.root.after(0, lambda: messagebox.showerror("错误", "Provider 配置错误,请检查配置"))
self.root.after(0, self.recognition_completed)
return
self.current_results.clear()
for i, img in enumerate(self.uploaded_images):
@@ -575,12 +650,13 @@ class MultiModalFoodApp:
result = classify_image(
image_path=img['path'],
labels=labels,
provider=provider,
fewshot_hints=use_hints,
ingredient_only=ingredient_only,
)
pred = result.get("label", "Unknown")
conf = result.get("confidence", 0.0)
# 若非数值后续展示为 N/A
# 若非数值,后续展示为 N/A
if not isinstance(conf, (int, float)):
try:
conf = float(conf)
@@ -619,6 +695,33 @@ class MultiModalFoodApp:
self.root.after(0, lambda: messagebox.showerror("错误", f"识别过程中出错: {e}"))
self.root.after(0, self.recognition_completed)
def _create_vlm_provider(self) -> Optional[VLMProvider]:
"""根据配置创建 VLM Provider"""
provider_type = self.provider_var.get()
try:
if provider_type == "ollama":
url = self.ollama_url_var.get().strip() or DEFAULT_OLLAMA_URL
model = self.vlm_model_var.get().strip() or DEFAULT_VLM_MODEL
return OllamaProvider(ollama_url=url, model=model)
elif provider_type == "kimi":
api_key = self.kimi_api_key_var.get().strip()
if not api_key:
messagebox.showerror("错误", "Kimi API Key 不能为空")
return None
base_url = self.kimi_base_url_var.get().strip() or "https://api.moonshot.cn/v1"
model = self.kimi_model_var.get().strip() or "moonshot-v1-32k-vision-preview"
return KimiProvider(api_key=api_key, base_url=base_url, model=model)
else:
messagebox.showerror("错误", f"未知的 Provider 类型: {provider_type}")
return None
except Exception as e:
messagebox.showerror("错误", f"创建 Provider 失败: {e}")
return None
def update_progress(self, current: int, total: int):
self.recognize_button.configure(text=f"识别中... ({current}/{total})")
self.update_images_display()
@@ -631,6 +734,142 @@ class MultiModalFoodApp:
self.update_stats()
messagebox.showinfo("完成", f"所有图片识别完成!耗时: {self.recognition_duration:.2f}")
# -------------------- 配置管理 --------------------
def get_config_path(self) -> str:
"""获取配置文件路径(项目根目录下的 config.json"""
return os.path.join(os.path.dirname(os.path.dirname(__file__)), "vlm_config.json")
def save_config(self):
"""保存当前配置到 JSON 文件"""
config = {
"mode": self.mode_var.get(),
"provider": self.provider_var.get(),
"ollama": {
"url": self.ollama_url_var.get(),
"model": self.vlm_model_var.get(),
},
"kimi": {
"api_key": self.kimi_api_key_var.get(),
"base_url": self.kimi_base_url_var.get(),
"model": self.kimi_model_var.get(),
},
"alias_map_path": self.alias_map_path,
"fewshot_enabled": self.fewshot_enabled_var.get(),
"fewshot_hints": self.fewshot_hints,
"fewshot_file_path": self.fewshot_file_path,
"extra_labels": self.extra_labels,
"extra_labels_file_path": self.extra_labels_file_path,
}
try:
config_path = self.get_config_path()
with open(config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
messagebox.showinfo("成功", f"配置已保存到 {os.path.basename(config_path)}")
except Exception as e:
messagebox.showerror("错误", f"保存配置失败: {e}")
def load_config(self):
"""自动加载配置(启动时调用)"""
config_path = self.get_config_path()
if not os.path.exists(config_path):
return
try:
with open(config_path, "r", encoding="utf-8") as f:
config = json.load(f)
# 恢复配置
if "mode" in config:
self.mode_var.set(config["mode"])
if "provider" in config:
self.provider_var.set(config["provider"])
if "ollama" in config:
self.ollama_url_var.set(config["ollama"].get("url", DEFAULT_OLLAMA_URL))
self.vlm_model_var.set(config["ollama"].get("model", DEFAULT_VLM_MODEL))
if "kimi" in config:
self.kimi_api_key_var.set(config["kimi"].get("api_key", ""))
self.kimi_base_url_var.set(config["kimi"].get("base_url", "https://api.moonshot.cn/v1"))
self.kimi_model_var.set(config["kimi"].get("model", "moonshot-v1-32k-vision-preview"))
if "alias_map_path" in config and config["alias_map_path"]:
self.alias_map_path = config["alias_map_path"]
self.alias_label_var.set(os.path.basename(self.alias_map_path))
if "fewshot_enabled" in config:
self.fewshot_enabled_var.set(config["fewshot_enabled"])
if "fewshot_hints" in config:
self.fewshot_hints = config["fewshot_hints"]
if "fewshot_file_path" in config:
self.fewshot_file_path = config["fewshot_file_path"]
if "extra_labels" in config:
self.extra_labels = config["extra_labels"]
if "extra_labels_file_path" in config:
self.extra_labels_file_path = config["extra_labels_file_path"]
self.refresh_extra_labels_view()
self.refresh_fewshot_view()
self.on_provider_change()
print(f"[Config] 配置已从 {config_path} 加载")
except Exception as e:
print(f"[Config] 加载配置失败: {e}")
def load_config_from_file(self):
"""从用户选择的文件加载配置"""
path = filedialog.askopenfilename(
title="选择配置文件",
filetypes=[("JSON 文件", "*.json"), ("所有文件", "*.*")]
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as f:
config = json.load(f)
# 恢复配置(同上)
if "mode" in config:
self.mode_var.set(config["mode"])
if "provider" in config:
self.provider_var.set(config["provider"])
if "ollama" in config:
self.ollama_url_var.set(config["ollama"].get("url", DEFAULT_OLLAMA_URL))
self.vlm_model_var.set(config["ollama"].get("model", DEFAULT_VLM_MODEL))
if "kimi" in config:
self.kimi_api_key_var.set(config["kimi"].get("api_key", ""))
self.kimi_base_url_var.set(config["kimi"].get("base_url", "https://api.moonshot.cn/v1"))
self.kimi_model_var.set(config["kimi"].get("model", "moonshot-v1-32k-vision-preview"))
if "alias_map_path" in config and config["alias_map_path"]:
self.alias_map_path = config["alias_map_path"]
self.alias_label_var.set(os.path.basename(self.alias_map_path))
if "fewshot_enabled" in config:
self.fewshot_enabled_var.set(config["fewshot_enabled"])
if "fewshot_hints" in config:
self.fewshot_hints = config["fewshot_hints"]
if "fewshot_file_path" in config:
self.fewshot_file_path = config["fewshot_file_path"]
if "extra_labels" in config:
self.extra_labels = config["extra_labels"]
if "extra_labels_file_path" in config:
self.extra_labels_file_path = config["extra_labels_file_path"]
self.refresh_extra_labels_view()
self.refresh_fewshot_view()
self.on_provider_change()
messagebox.showinfo("成功", f"配置已从 {os.path.basename(path)} 加载")
except Exception as e:
messagebox.showerror("错误", f"加载配置失败: {e}")
def main():
root = TkinterDnD.Tk() # 必须使用 TkinterDnD.Tk 以支持拖拽