- database.py: 新增 get_api_key/set_api_key/delete_api_key/get_all_api_keys 函数 - qwen.py/deepseek.py/analyzer.py: 改用 db.get_api_key() 读取配置 - app.py: 新增管理员 API 接口 (/api/admin/api-keys) - settings 页面: 管理员可见的 API 密钥配置区域 API Key 优先级: 数据库 > 环境变量 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
155 lines
4.6 KiB
Python
155 lines
4.6 KiB
Python
"""食物图片识别分析器"""
|
|
|
|
import base64
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from ..core.calories import estimate_meal_calories
|
|
|
|
|
|
class FoodAnalyzer:
|
|
"""食物分析器基类"""
|
|
|
|
def analyze(self, image_path: Path) -> dict:
|
|
"""
|
|
分析食物图片
|
|
|
|
返回:
|
|
{
|
|
"description": "米饭、红烧排骨、西兰花",
|
|
"total_calories": 680,
|
|
"total_protein": 25.0,
|
|
"total_carbs": 85.0,
|
|
"total_fat": 20.0,
|
|
"items": [...]
|
|
}
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class ClaudeFoodAnalyzer(FoodAnalyzer):
|
|
"""使用 Claude Vision API 的食物分析器"""
|
|
|
|
def __init__(self, api_key: Optional[str] = None):
|
|
from ..core import database as db
|
|
self.api_key = api_key or db.get_api_key("anthropic")
|
|
|
|
def analyze(self, image_path: Path) -> dict:
|
|
"""使用 Claude Vision 分析食物图片"""
|
|
if not self.api_key:
|
|
raise ValueError("需要设置 ANTHROPIC_API_KEY 环境变量")
|
|
|
|
from anthropic import Anthropic
|
|
|
|
client = Anthropic(api_key=self.api_key)
|
|
|
|
# 读取并编码图片
|
|
image_data = self._encode_image(image_path)
|
|
media_type = self._get_media_type(image_path)
|
|
|
|
# 调用 Claude Vision API
|
|
message = client.messages.create(
|
|
model="claude-sonnet-4-20250514",
|
|
max_tokens=1024,
|
|
messages=[
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "image",
|
|
"source": {
|
|
"type": "base64",
|
|
"media_type": media_type,
|
|
"data": image_data,
|
|
},
|
|
},
|
|
{
|
|
"type": "text",
|
|
"text": """请分析这张食物图片,识别出所有食物。
|
|
|
|
要求:
|
|
1. 列出所有可识别的食物,用中文名称
|
|
2. 估计每种食物的大致份量
|
|
3. 按照 "食物名称" 的格式返回,多个食物用加号连接
|
|
|
|
例如返回格式:米饭+红烧排骨+西兰花
|
|
|
|
只返回食物列表,不需要其他解释。"""
|
|
},
|
|
],
|
|
}
|
|
],
|
|
)
|
|
|
|
# 解析响应
|
|
description = message.content[0].text.strip()
|
|
|
|
# 使用卡路里计算模块估算营养成分
|
|
result = estimate_meal_calories(description)
|
|
result["description"] = description
|
|
|
|
return result
|
|
|
|
def _encode_image(self, image_path: Path) -> str:
|
|
"""将图片编码为 base64"""
|
|
with open(image_path, "rb") as f:
|
|
return base64.standard_b64encode(f.read()).decode("utf-8")
|
|
|
|
def _get_media_type(self, image_path: Path) -> str:
|
|
"""获取图片的 MIME 类型"""
|
|
suffix = image_path.suffix.lower()
|
|
media_types = {
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
}
|
|
return media_types.get(suffix, "image/jpeg")
|
|
|
|
|
|
class LocalFoodAnalyzer(FoodAnalyzer):
|
|
"""本地食物分析器(简单实现,基于文件名)"""
|
|
|
|
def analyze(self, image_path: Path) -> dict:
|
|
"""
|
|
简单的本地分析器
|
|
实际使用时可以替换为本地 AI 模型
|
|
"""
|
|
# 目前仅返回空结果,提示用户手动输入
|
|
return {
|
|
"description": "",
|
|
"total_calories": 0,
|
|
"total_protein": 0,
|
|
"total_carbs": 0,
|
|
"total_fat": 0,
|
|
"items": [],
|
|
"note": "本地分析器暂不支持图片识别,请手动输入食物描述",
|
|
}
|
|
|
|
|
|
def get_analyzer(
|
|
provider: str = "qwen",
|
|
api_key: Optional[str] = None,
|
|
use_claude: bool = False, # 保留向后兼容
|
|
) -> FoodAnalyzer:
|
|
"""
|
|
获取食物分析器
|
|
|
|
provider: "qwen" | "deepseek" | "claude" | "local"
|
|
"""
|
|
# 向后兼容: 如果使用旧参数 use_claude=True
|
|
if use_claude:
|
|
provider = "claude"
|
|
|
|
if provider == "qwen":
|
|
from .providers.qwen import get_qwen_analyzer
|
|
return get_qwen_analyzer(api_key)
|
|
elif provider == "deepseek":
|
|
from .providers.deepseek import get_deepseek_analyzer
|
|
return get_deepseek_analyzer(api_key)
|
|
elif provider == "claude":
|
|
return ClaudeFoodAnalyzer(api_key)
|
|
else:
|
|
return LocalFoodAnalyzer()
|