Spaces:
Sleeping
Sleeping
Commit
·
a608ddf
1
Parent(s):
f9b12dd
Update health assistant minimal with new services and improvements
Browse files- __pycache__/__init__.cpython-313.pyc +0 -0
- app/routers/ai_router.py +100 -16
- app/services/integrated_food_analysis_service.py +234 -0
- app/services/nutrition_api_service.py +1 -1
- app/services/reference_detection_service.py +198 -0
- app/services/weight_calculation_service.py +309 -0
- app/services/weight_estimation_service.py +172 -108
__pycache__/__init__.cpython-313.pyc
CHANGED
|
Binary files a/__pycache__/__init__.cpython-313.pyc and b/__pycache__/__init__.cpython-313.pyc differ
|
|
|
app/routers/ai_router.py
CHANGED
|
@@ -3,6 +3,14 @@
|
|
| 3 |
from fastapi import APIRouter, File, UploadFile, HTTPException
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from typing import Dict, Any, List, Optional
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
router = APIRouter(
|
| 8 |
prefix="/ai",
|
|
@@ -10,6 +18,16 @@ router = APIRouter(
|
|
| 10 |
)
|
| 11 |
|
| 12 |
# 新增 Pydantic 模型定義
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
class WeightEstimationResponse(BaseModel):
|
| 14 |
food_type: str
|
| 15 |
estimated_weight: float
|
|
@@ -33,29 +51,86 @@ async def analyze_food_image_endpoint(file: UploadFile = File(...)):
|
|
| 33 |
if not file.content_type or not file.content_type.startswith("image/"):
|
| 34 |
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
@router.post("/analyze-food-image-with-weight/", response_model=WeightEstimationResponse)
|
| 40 |
async def analyze_food_image_with_weight_endpoint(file: UploadFile = File(...)):
|
| 41 |
"""
|
| 42 |
整合食物辨識、重量估算與營養分析的端點。
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
"""
|
| 45 |
# 檢查上傳的檔案是否為圖片格式
|
| 46 |
if not file.content_type or not file.content_type.startswith("image/"):
|
| 47 |
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 48 |
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
@router.get("/health")
|
| 61 |
async def health_check():
|
|
@@ -65,8 +140,17 @@ async def health_check():
|
|
| 65 |
return {
|
| 66 |
"status": "healthy",
|
| 67 |
"services": {
|
| 68 |
-
"food_classification": "available",
|
| 69 |
-
"
|
| 70 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
}
|
|
|
|
| 3 |
from fastapi import APIRouter, File, UploadFile, HTTPException
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from typing import Dict, Any, List, Optional
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
# 導入新的整合服務
|
| 9 |
+
from ..services.integrated_food_analysis_service import analyze_food_image_integrated
|
| 10 |
+
|
| 11 |
+
# 設置日誌
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
router = APIRouter(
|
| 16 |
prefix="/ai",
|
|
|
|
| 18 |
)
|
| 19 |
|
| 20 |
# 新增 Pydantic 模型定義
|
| 21 |
+
class IntegratedAnalysisResponse(BaseModel):
|
| 22 |
+
success: bool
|
| 23 |
+
analysis_time: float
|
| 24 |
+
food_analysis: Dict[str, Any]
|
| 25 |
+
reference_analysis: Dict[str, Any]
|
| 26 |
+
weight_analysis: Dict[str, Any]
|
| 27 |
+
nutrition_analysis: Dict[str, Any]
|
| 28 |
+
summary: Dict[str, Any]
|
| 29 |
+
architecture: Dict[str, str]
|
| 30 |
+
|
| 31 |
class WeightEstimationResponse(BaseModel):
|
| 32 |
food_type: str
|
| 33 |
estimated_weight: float
|
|
|
|
| 51 |
if not file.content_type or not file.content_type.startswith("image/"):
|
| 52 |
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 53 |
|
| 54 |
+
try:
|
| 55 |
+
# 讀取圖片數據
|
| 56 |
+
image_bytes = await file.read()
|
| 57 |
+
|
| 58 |
+
# 使用新的整合服務進行分析
|
| 59 |
+
result = analyze_food_image_integrated(image_bytes, debug=False)
|
| 60 |
+
|
| 61 |
+
if not result.get("success", False):
|
| 62 |
+
raise HTTPException(status_code=500, detail=result.get("error_message", "分析失敗"))
|
| 63 |
+
|
| 64 |
+
# 返回簡化的結果
|
| 65 |
+
return {
|
| 66 |
+
"food_name": result["food_analysis"]["food_name"],
|
| 67 |
+
"nutrition_info": result["nutrition_analysis"]["adjusted_nutrition"]
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"食物分析失敗: {str(e)}")
|
| 72 |
+
raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
|
| 73 |
|
| 74 |
@router.post("/analyze-food-image-with-weight/", response_model=WeightEstimationResponse)
|
| 75 |
async def analyze_food_image_with_weight_endpoint(file: UploadFile = File(...)):
|
| 76 |
"""
|
| 77 |
整合食物辨識、重量估算與營養分析的端點。
|
| 78 |
+
使用新的架構:FOOD101 → YOLO(參考物) → SAM+DPT → USDA API
|
| 79 |
+
"""
|
| 80 |
+
# 檢查上傳的檔案是否為圖片格式
|
| 81 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 82 |
+
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
# 讀取圖片數據
|
| 86 |
+
image_bytes = await file.read()
|
| 87 |
+
|
| 88 |
+
# 使用新的整合服務進行分析
|
| 89 |
+
result = analyze_food_image_integrated(image_bytes, debug=False)
|
| 90 |
+
|
| 91 |
+
if not result.get("success", False):
|
| 92 |
+
raise HTTPException(status_code=500, detail=result.get("error_message", "分析失敗"))
|
| 93 |
+
|
| 94 |
+
# 轉換為舊格式以保持向後兼容
|
| 95 |
+
weight_analysis = result["weight_analysis"]
|
| 96 |
+
nutrition_analysis = result["nutrition_analysis"]
|
| 97 |
+
|
| 98 |
+
return WeightEstimationResponse(
|
| 99 |
+
food_type=result["food_analysis"]["food_name"],
|
| 100 |
+
estimated_weight=weight_analysis["estimated_weight"],
|
| 101 |
+
weight_confidence=weight_analysis["weight_confidence"],
|
| 102 |
+
weight_error_range=weight_analysis["weight_error_range"],
|
| 103 |
+
nutrition=nutrition_analysis["adjusted_nutrition"],
|
| 104 |
+
reference_object=weight_analysis["reference_object"],
|
| 105 |
+
note=f"使用新架構分析,耗時 {result['analysis_time']} 秒"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"重量估算分析失敗: {str(e)}")
|
| 110 |
+
raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
|
| 111 |
+
|
| 112 |
+
@router.post("/analyze-food-image-integrated/")
|
| 113 |
+
async def analyze_food_image_integrated_endpoint(file: UploadFile = File(...)):
|
| 114 |
+
"""
|
| 115 |
+
新的整合分析端點,返回完整的分析結果
|
| 116 |
+
架構:FOOD101 → YOLO(參考物) → SAM+DPT → USDA API
|
| 117 |
"""
|
| 118 |
# 檢查上傳的檔案是否為圖片格式
|
| 119 |
if not file.content_type or not file.content_type.startswith("image/"):
|
| 120 |
raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
|
| 121 |
|
| 122 |
+
try:
|
| 123 |
+
# 讀取圖片數據
|
| 124 |
+
image_bytes = await file.read()
|
| 125 |
+
|
| 126 |
+
# 使用新的整合服務進行分析
|
| 127 |
+
result = analyze_food_image_integrated(image_bytes, debug=False)
|
| 128 |
+
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"整合分析失敗: {str(e)}")
|
| 133 |
+
raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
|
| 134 |
|
| 135 |
@router.get("/health")
|
| 136 |
async def health_check():
|
|
|
|
| 140 |
return {
|
| 141 |
"status": "healthy",
|
| 142 |
"services": {
|
| 143 |
+
"food_classification": "available (FOOD101)",
|
| 144 |
+
"reference_detection": "available (YOLO)",
|
| 145 |
+
"weight_estimation": "available (SAM+DPT)",
|
| 146 |
+
"nutrition_api": "available (USDA)",
|
| 147 |
+
"integrated_analysis": "available"
|
| 148 |
+
},
|
| 149 |
+
"architecture": {
|
| 150 |
+
"layer_1": "FOOD101 (食物識別)",
|
| 151 |
+
"layer_2": "YOLO (參考物偵測)",
|
| 152 |
+
"layer_3": "SAM+DPT (重量計算)",
|
| 153 |
+
"layer_4": "USDA API (營養查詢)",
|
| 154 |
+
"layer_5": "重量調整 (營養計算)"
|
| 155 |
}
|
| 156 |
}
|
app/services/integrated_food_analysis_service.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 檔案路徑: app/services/integrated_food_analysis_service.py
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import io
|
| 7 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
|
| 10 |
+
# 導入各個服務
|
| 11 |
+
from .ai_service import classify_food_image
|
| 12 |
+
from .reference_detection_service import detect_reference_objects_from_image
|
| 13 |
+
from .weight_calculation_service import calculate_food_weight
|
| 14 |
+
from .nutrition_api_service import fetch_nutrition_data
|
| 15 |
+
|
| 16 |
+
# 設置日誌
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
class IntegratedFoodAnalysisService:
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""初始化整合食物分析服務"""
|
| 23 |
+
logger.info("初始化整合食物分析服務...")
|
| 24 |
+
|
| 25 |
+
def analyze_food_image(self, image_bytes: bytes, debug: bool = False) -> Dict[str, Any]:
|
| 26 |
+
"""
|
| 27 |
+
整合食物分析主函數
|
| 28 |
+
|
| 29 |
+
新架構流程:
|
| 30 |
+
1. FOOD101 模型判斷食物
|
| 31 |
+
2. YOLO 主要判斷參考物在哪、大小為何
|
| 32 |
+
3. 再利用 SAM+DPT 去計算可能的重量
|
| 33 |
+
4. 再利用重量去乘上 USDA 每100克的數值
|
| 34 |
+
|
| 35 |
+
Args:
|
| 36 |
+
image_bytes: 圖片二進位數據
|
| 37 |
+
debug: 是否啟用調試模式
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
Dict: 完整的分析結果
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
logger.info("=== 開始整合食物分析 ===")
|
| 44 |
+
start_time = datetime.now()
|
| 45 |
+
|
| 46 |
+
# 將 bytes 轉換為 PIL Image
|
| 47 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 48 |
+
logger.info(f"圖片載入完成,尺寸: {image.size}")
|
| 49 |
+
|
| 50 |
+
# === 第一層:FOOD101 模型判斷食物 ===
|
| 51 |
+
logger.info("--- 第一層:FOOD101 食物識別 ---")
|
| 52 |
+
food_name = classify_food_image(image_bytes)
|
| 53 |
+
logger.info(f"FOOD101 識別結果: {food_name}")
|
| 54 |
+
|
| 55 |
+
if food_name.startswith("Error") or food_name == "Unknown":
|
| 56 |
+
return self._create_error_response("食物識別失敗", food_name)
|
| 57 |
+
|
| 58 |
+
# === 第二層:YOLO 判斷參考物 ===
|
| 59 |
+
logger.info("--- 第二層:YOLO 參考物偵測 ---")
|
| 60 |
+
reference_objects, pixel_ratio = detect_reference_objects_from_image(image_bytes)
|
| 61 |
+
|
| 62 |
+
if not reference_objects:
|
| 63 |
+
logger.warning("未偵測到參考物,使用預設像素比例")
|
| 64 |
+
pixel_ratio = 0.01 # 預設比例
|
| 65 |
+
|
| 66 |
+
best_reference = reference_objects[0] if reference_objects else None
|
| 67 |
+
logger.info(f"參考物偵測結果: {len(reference_objects)} 個參考物")
|
| 68 |
+
if best_reference:
|
| 69 |
+
logger.info(f"最佳參考物: {best_reference['label']}, 信心度: {best_reference['confidence']:.2f}")
|
| 70 |
+
logger.info(f"像素比例: {pixel_ratio:.4f} cm/pixel")
|
| 71 |
+
|
| 72 |
+
# === 第三層:SAM+DPT 重量計算 ===
|
| 73 |
+
logger.info("--- 第三層:SAM+DPT 重量計算 ---")
|
| 74 |
+
weight_result = calculate_food_weight(
|
| 75 |
+
image_bytes=image_bytes,
|
| 76 |
+
food_name=food_name,
|
| 77 |
+
pixel_ratio=pixel_ratio,
|
| 78 |
+
bbox=None # 使用整個圖片
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
if not weight_result.get("success", False):
|
| 82 |
+
logger.error("重量計算失敗")
|
| 83 |
+
return self._create_error_response("重量計算失敗", weight_result.get("error", "未知錯誤"))
|
| 84 |
+
|
| 85 |
+
estimated_weight = weight_result["estimated_weight"]
|
| 86 |
+
weight_confidence = weight_result["weight_confidence"]
|
| 87 |
+
weight_error_range = weight_result["weight_error_range"]
|
| 88 |
+
|
| 89 |
+
logger.info(f"重量計算結果: {estimated_weight}g, 信心度: {weight_confidence:.2f}")
|
| 90 |
+
|
| 91 |
+
# === 第四層:USDA API 營養查詢 ===
|
| 92 |
+
logger.info("--- 第四層:USDA API 營養查詢 ---")
|
| 93 |
+
nutrition_info = fetch_nutrition_data(food_name)
|
| 94 |
+
|
| 95 |
+
if nutrition_info is None:
|
| 96 |
+
logger.warning("USDA API 查詢失敗,使用預設營養值")
|
| 97 |
+
nutrition_info = self._get_default_nutrition(food_name)
|
| 98 |
+
|
| 99 |
+
# === 第五層:根據重量調整營養素 ===
|
| 100 |
+
logger.info("--- 第五層:重量調整營養素 ---")
|
| 101 |
+
weight_ratio = estimated_weight / 100 # 每100克的營養值
|
| 102 |
+
adjusted_nutrition = {}
|
| 103 |
+
|
| 104 |
+
for nutrient, value in nutrition_info.items():
|
| 105 |
+
if nutrient not in ["food_name", "chinese_name"]:
|
| 106 |
+
adjusted_nutrition[nutrient] = round(value * weight_ratio, 1)
|
| 107 |
+
|
| 108 |
+
logger.info(f"營養調整完成,重量比例: {weight_ratio:.2f}")
|
| 109 |
+
|
| 110 |
+
# === 生成分析報告 ===
|
| 111 |
+
analysis_time = (datetime.now() - start_time).total_seconds()
|
| 112 |
+
|
| 113 |
+
result = {
|
| 114 |
+
"success": True,
|
| 115 |
+
"analysis_time": round(analysis_time, 2),
|
| 116 |
+
"food_analysis": {
|
| 117 |
+
"food_name": food_name,
|
| 118 |
+
"recognition_method": "FOOD101",
|
| 119 |
+
"confidence": 0.95 # FOOD101 通常有很高的準確度
|
| 120 |
+
},
|
| 121 |
+
"reference_analysis": {
|
| 122 |
+
"detected_objects": reference_objects,
|
| 123 |
+
"best_reference": best_reference,
|
| 124 |
+
"pixel_ratio": pixel_ratio,
|
| 125 |
+
"detection_method": "YOLO"
|
| 126 |
+
},
|
| 127 |
+
"weight_analysis": {
|
| 128 |
+
"estimated_weight": estimated_weight,
|
| 129 |
+
"weight_confidence": weight_confidence,
|
| 130 |
+
"weight_error_range": weight_error_range,
|
| 131 |
+
"calculation_method": "SAM+DPT",
|
| 132 |
+
"reference_object": best_reference["label"] if best_reference else None
|
| 133 |
+
},
|
| 134 |
+
"nutrition_analysis": {
|
| 135 |
+
"base_nutrition": nutrition_info, # 每100克的營養值
|
| 136 |
+
"adjusted_nutrition": adjusted_nutrition, # 根據重量調整的營養值
|
| 137 |
+
"data_source": "USDA API",
|
| 138 |
+
"weight_ratio": weight_ratio
|
| 139 |
+
},
|
| 140 |
+
"summary": {
|
| 141 |
+
"total_calories": adjusted_nutrition.get("calories", 0),
|
| 142 |
+
"total_protein": adjusted_nutrition.get("protein", 0),
|
| 143 |
+
"total_carbs": adjusted_nutrition.get("carbs", 0),
|
| 144 |
+
"total_fat": adjusted_nutrition.get("fat", 0),
|
| 145 |
+
"health_score": self._calculate_health_score(adjusted_nutrition)
|
| 146 |
+
},
|
| 147 |
+
"architecture": {
|
| 148 |
+
"layer_1": "FOOD101 (食物識別)",
|
| 149 |
+
"layer_2": "YOLO (參考物偵測)",
|
| 150 |
+
"layer_3": "SAM+DPT (重量計算)",
|
| 151 |
+
"layer_4": "USDA API (營養查詢)",
|
| 152 |
+
"layer_5": "重量調整 (營養計算)"
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
logger.info("=== 整合食物分析完成 ===")
|
| 157 |
+
return result
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"整合食物分析失敗: {str(e)}")
|
| 161 |
+
return self._create_error_response("整合分析失敗", str(e))
|
| 162 |
+
|
| 163 |
+
def _create_error_response(self, error_type: str, error_message: str) -> Dict[str, Any]:
|
| 164 |
+
"""創建錯誤回應"""
|
| 165 |
+
return {
|
| 166 |
+
"success": False,
|
| 167 |
+
"error_type": error_type,
|
| 168 |
+
"error_message": error_message,
|
| 169 |
+
"timestamp": datetime.now().isoformat()
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def _get_default_nutrition(self, food_name: str) -> Dict[str, Any]:
|
| 173 |
+
"""取得預設營養值"""
|
| 174 |
+
default_nutrition = {
|
| 175 |
+
"food_name": food_name,
|
| 176 |
+
"calories": 100,
|
| 177 |
+
"protein": 5,
|
| 178 |
+
"fat": 2,
|
| 179 |
+
"carbs": 15,
|
| 180 |
+
"fiber": 2,
|
| 181 |
+
"sugar": 1,
|
| 182 |
+
"sodium": 200
|
| 183 |
+
}
|
| 184 |
+
return default_nutrition
|
| 185 |
+
|
| 186 |
+
def _calculate_health_score(self, nutrition: Dict[str, float]) -> int:
|
| 187 |
+
"""計算健康評分"""
|
| 188 |
+
score = 100
|
| 189 |
+
|
| 190 |
+
# 熱量評分
|
| 191 |
+
calories = nutrition.get("calories", 0)
|
| 192 |
+
if calories > 400:
|
| 193 |
+
score -= 20
|
| 194 |
+
elif calories > 300:
|
| 195 |
+
score -= 10
|
| 196 |
+
|
| 197 |
+
# 脂肪評分
|
| 198 |
+
fat = nutrition.get("fat", 0)
|
| 199 |
+
if fat > 20:
|
| 200 |
+
score -= 15
|
| 201 |
+
elif fat > 15:
|
| 202 |
+
score -= 8
|
| 203 |
+
|
| 204 |
+
# 蛋白質評分
|
| 205 |
+
protein = nutrition.get("protein", 0)
|
| 206 |
+
if protein > 15:
|
| 207 |
+
score += 10
|
| 208 |
+
elif protein < 5:
|
| 209 |
+
score -= 10
|
| 210 |
+
|
| 211 |
+
# 鈉含量評分
|
| 212 |
+
sodium = nutrition.get("sodium", 0)
|
| 213 |
+
if sodium > 800:
|
| 214 |
+
score -= 15
|
| 215 |
+
elif sodium > 600:
|
| 216 |
+
score -= 8
|
| 217 |
+
|
| 218 |
+
return max(0, min(100, score))
|
| 219 |
+
|
| 220 |
+
# 全域服務實例
|
| 221 |
+
integrated_service = IntegratedFoodAnalysisService()
|
| 222 |
+
|
| 223 |
+
def analyze_food_image_integrated(image_bytes: bytes, debug: bool = False) -> Dict[str, Any]:
|
| 224 |
+
"""
|
| 225 |
+
整合食物分析的外部接口
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
image_bytes: 圖片二進位數據
|
| 229 |
+
debug: 是否啟用調試模式
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
Dict: 完整的分析結果
|
| 233 |
+
"""
|
| 234 |
+
return integrated_service.analyze_food_image(image_bytes, debug)
|
app/services/nutrition_api_service.py
CHANGED
|
@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
|
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
# 從環境變數中獲取 API 金鑰
|
| 15 |
-
USDA_API_KEY = os.getenv("USDA_API_KEY", "
|
| 16 |
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
|
| 17 |
|
| 18 |
# 我們關心的主要營養素及其在 USDA API 中的名稱或編號
|
|
|
|
| 12 |
load_dotenv()
|
| 13 |
|
| 14 |
# 從環境變數中獲取 API 金鑰
|
| 15 |
+
USDA_API_KEY = os.getenv("USDA_API_KEY", "4guYMPsU2jSnN6GH6NjexZmSh1VWrgmOIoH6d6ju")
|
| 16 |
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
|
| 17 |
|
| 18 |
# 我們關心的主要營養素及其在 USDA API 中的名稱或編號
|
app/services/reference_detection_service.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 檔案路徑: app/services/reference_detection_service.py
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 7 |
+
from ultralytics import YOLO
|
| 8 |
+
import io
|
| 9 |
+
|
| 10 |
+
# 設置日誌
|
| 11 |
+
logging.basicConfig(level=logging.INFO)
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# 參考物尺寸表 (cm)
|
| 15 |
+
REFERENCE_OBJECTS = {
|
| 16 |
+
"plate": {"diameter": 24.0, "type": "circular"}, # 標準餐盤直徑
|
| 17 |
+
"bowl": {"diameter": 15.0, "type": "circular"}, # 標準碗直徑
|
| 18 |
+
"spoon": {"length": 15.0, "type": "linear"}, # 湯匙長度
|
| 19 |
+
"fork": {"length": 20.0, "type": "linear"}, # 叉子長度
|
| 20 |
+
"knife": {"length": 20.0, "type": "linear"}, # 刀子長度
|
| 21 |
+
"coin": {"diameter": 2.4, "type": "circular"}, # 硬幣直徑
|
| 22 |
+
"credit_card": {"length": 8.5, "width": 5.4, "type": "rectangular"}, # 信用卡
|
| 23 |
+
"default": {"diameter": 24.0, "type": "circular"} # 預設參考物
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
class ReferenceDetectionService:
|
| 27 |
+
def __init__(self):
|
| 28 |
+
"""初始化參考物偵測服務"""
|
| 29 |
+
self.yolo_model = None
|
| 30 |
+
self._load_model()
|
| 31 |
+
|
| 32 |
+
def _load_model(self):
|
| 33 |
+
"""載入 YOLO 模型"""
|
| 34 |
+
try:
|
| 35 |
+
logger.info("正在載入 YOLO 參考物偵測模型...")
|
| 36 |
+
# 使用 YOLOv8n 作為基礎模型
|
| 37 |
+
self.yolo_model = YOLO("yolov8n.pt")
|
| 38 |
+
logger.info("YOLO 模型載入完成!")
|
| 39 |
+
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"YOLO 模型載入失敗: {str(e)}")
|
| 42 |
+
raise
|
| 43 |
+
|
| 44 |
+
def detect_reference_objects(self, image: Image.Image) -> List[Dict[str, Any]]:
|
| 45 |
+
"""
|
| 46 |
+
使用 YOLO 偵測圖片中的參考物
|
| 47 |
+
|
| 48 |
+
Args:
|
| 49 |
+
image: PIL Image 物件
|
| 50 |
+
|
| 51 |
+
Returns:
|
| 52 |
+
List[Dict]: 包含參考物資訊的列表
|
| 53 |
+
"""
|
| 54 |
+
try:
|
| 55 |
+
results = self.yolo_model(image)
|
| 56 |
+
reference_objects = []
|
| 57 |
+
|
| 58 |
+
for result in results[0].boxes.data.tolist():
|
| 59 |
+
x1, y1, x2, y2, conf, class_id = result
|
| 60 |
+
label = self.yolo_model.model.names[int(class_id)].lower()
|
| 61 |
+
|
| 62 |
+
# 只關注參考物類別
|
| 63 |
+
if self._is_reference_object(label) and conf > 0.3:
|
| 64 |
+
reference_objects.append({
|
| 65 |
+
"label": label,
|
| 66 |
+
"bbox": [x1, y1, x2, y2],
|
| 67 |
+
"confidence": conf,
|
| 68 |
+
"area": (x2 - x1) * (y2 - y1), # 像素面積
|
| 69 |
+
"dimensions": self._get_reference_dimensions(label)
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
# 按信心度排序,優先選擇高信心度的參考物
|
| 73 |
+
reference_objects.sort(key=lambda x: x["confidence"], reverse=True)
|
| 74 |
+
|
| 75 |
+
logger.info(f"偵測到 {len(reference_objects)} 個參考物: {[obj['label'] for obj in reference_objects]}")
|
| 76 |
+
return reference_objects
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"參考物偵測失敗: {str(e)}")
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
def _is_reference_object(self, label: str) -> bool:
|
| 83 |
+
"""判斷是否為參考物"""
|
| 84 |
+
reference_labels = [
|
| 85 |
+
"plate", "bowl", "spoon", "fork", "knife",
|
| 86 |
+
"coin", "credit card", "card", "phone", "remote"
|
| 87 |
+
]
|
| 88 |
+
return any(ref_label in label for ref_label in reference_labels)
|
| 89 |
+
|
| 90 |
+
def _get_reference_dimensions(self, label: str) -> Dict[str, Any]:
|
| 91 |
+
"""取得參考物的實際尺寸"""
|
| 92 |
+
for ref_name, dimensions in REFERENCE_OBJECTS.items():
|
| 93 |
+
if ref_name in label:
|
| 94 |
+
return dimensions
|
| 95 |
+
return REFERENCE_OBJECTS["default"]
|
| 96 |
+
|
| 97 |
+
def calculate_pixel_ratio(self, reference_object: Dict[str, Any]) -> float:
|
| 98 |
+
"""
|
| 99 |
+
根據參考物計算像素到實際距離的比例
|
| 100 |
+
|
| 101 |
+
Args:
|
| 102 |
+
reference_object: 參考物資訊
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
float: 像素比例 (cm/pixel)
|
| 106 |
+
"""
|
| 107 |
+
try:
|
| 108 |
+
bbox = reference_object["bbox"]
|
| 109 |
+
dimensions = reference_object["dimensions"]
|
| 110 |
+
|
| 111 |
+
# 計算參考物在圖片中的像素尺寸
|
| 112 |
+
pixel_width = bbox[2] - bbox[0]
|
| 113 |
+
pixel_height = bbox[3] - bbox[1]
|
| 114 |
+
|
| 115 |
+
if dimensions["type"] == "circular":
|
| 116 |
+
# 圓形參考物(如餐盤、碗、硬幣)
|
| 117 |
+
pixel_diameter = min(pixel_width, pixel_height) # 取較小值作為直徑
|
| 118 |
+
actual_diameter = dimensions["diameter"]
|
| 119 |
+
pixel_ratio = actual_diameter / pixel_diameter
|
| 120 |
+
|
| 121 |
+
elif dimensions["type"] == "linear":
|
| 122 |
+
# 線性參考物(如餐具)
|
| 123 |
+
pixel_length = max(pixel_width, pixel_height) # 取較大值作為長度
|
| 124 |
+
actual_length = dimensions["length"]
|
| 125 |
+
pixel_ratio = actual_length / pixel_length
|
| 126 |
+
|
| 127 |
+
elif dimensions["type"] == "rectangular":
|
| 128 |
+
# 矩形參考物(如信用卡)
|
| 129 |
+
pixel_length = max(pixel_width, pixel_height)
|
| 130 |
+
actual_length = dimensions["length"]
|
| 131 |
+
pixel_ratio = actual_length / pixel_length
|
| 132 |
+
|
| 133 |
+
else:
|
| 134 |
+
# 預設情況
|
| 135 |
+
pixel_ratio = 0.01 # 100像素 = 1cm
|
| 136 |
+
|
| 137 |
+
logger.info(f"參考物 {reference_object['label']} 像素比例: {pixel_ratio:.4f} cm/pixel")
|
| 138 |
+
return pixel_ratio
|
| 139 |
+
|
| 140 |
+
except Exception as e:
|
| 141 |
+
logger.error(f"計算像素比例失敗: {str(e)}")
|
| 142 |
+
return 0.01 # 預設值
|
| 143 |
+
|
| 144 |
+
def get_best_reference_object(self, reference_objects: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
| 145 |
+
"""
|
| 146 |
+
從偵測到的參考物中選擇最佳的參考物
|
| 147 |
+
|
| 148 |
+
Args:
|
| 149 |
+
reference_objects: 參考物列表
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Optional[Dict]: 最佳參考物,如果沒有則返回 None
|
| 153 |
+
"""
|
| 154 |
+
if not reference_objects:
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
# 優先選擇餐盤或碗,因為它們通常最穩定
|
| 158 |
+
priority_objects = ["plate", "bowl"]
|
| 159 |
+
|
| 160 |
+
for obj in reference_objects:
|
| 161 |
+
if any(priority in obj["label"] for priority in priority_objects):
|
| 162 |
+
return obj
|
| 163 |
+
|
| 164 |
+
# 如果沒有優先參考物,選擇信心度最高的
|
| 165 |
+
return reference_objects[0]
|
| 166 |
+
|
| 167 |
+
# 全域服務實例
|
| 168 |
+
reference_service = ReferenceDetectionService()
|
| 169 |
+
|
| 170 |
+
def detect_reference_objects_from_image(image_bytes: bytes) -> Tuple[List[Dict[str, Any]], Optional[float]]:
|
| 171 |
+
"""
|
| 172 |
+
從圖片中偵測參考物並計算像素比例
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
image_bytes: 圖片二進位數據
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
Tuple[List[Dict], Optional[float]]: (參考物列表, 像素比例)
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 182 |
+
|
| 183 |
+
# 偵測參考物
|
| 184 |
+
reference_objects = reference_service.detect_reference_objects(image)
|
| 185 |
+
|
| 186 |
+
# 選擇最佳參考物
|
| 187 |
+
best_reference = reference_service.get_best_reference_object(reference_objects)
|
| 188 |
+
|
| 189 |
+
# 計算像素比例
|
| 190 |
+
pixel_ratio = None
|
| 191 |
+
if best_reference:
|
| 192 |
+
pixel_ratio = reference_service.calculate_pixel_ratio(best_reference)
|
| 193 |
+
|
| 194 |
+
return reference_objects, pixel_ratio
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"參考物偵測失敗: {str(e)}")
|
| 198 |
+
return [], None
|
app/services/weight_calculation_service.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 檔案路徑: app/services/weight_calculation_service.py
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
import io
|
| 7 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 8 |
+
import torch
|
| 9 |
+
from transformers import SamModel, SamProcessor, pipeline
|
| 10 |
+
|
| 11 |
+
# 設置日誌
|
| 12 |
+
logging.basicConfig(level=logging.INFO)
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
# 食物密度表 (g/cm³) - 常見食物的平均密度
|
| 16 |
+
FOOD_DENSITY_TABLE = {
|
| 17 |
+
"rice": 0.8, # 米飯
|
| 18 |
+
"fried_rice": 0.7, # 炒飯
|
| 19 |
+
"noodles": 0.6, # 麵條
|
| 20 |
+
"bread": 0.3, # 麵包
|
| 21 |
+
"meat": 1.0, # 肉類
|
| 22 |
+
"fish": 1.1, # 魚類
|
| 23 |
+
"vegetables": 0.4, # 蔬菜
|
| 24 |
+
"fruits": 0.8, # 水果
|
| 25 |
+
"soup": 1.0, # 湯類
|
| 26 |
+
"sushi": 0.9, # 壽司
|
| 27 |
+
"pizza": 0.6, # 披薩
|
| 28 |
+
"hamburger": 0.7, # 漢堡
|
| 29 |
+
"salad": 0.3, # 沙拉
|
| 30 |
+
"default": 0.8 # 預設密度
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
class WeightCalculationService:
|
| 34 |
+
def __init__(self):
|
| 35 |
+
"""初始化重量計算服務"""
|
| 36 |
+
self.sam_model = None
|
| 37 |
+
self.sam_processor = None
|
| 38 |
+
self.dpt_model = None
|
| 39 |
+
self._load_models()
|
| 40 |
+
|
| 41 |
+
def _load_models(self):
|
| 42 |
+
"""載入 SAM 和 DPT 模型"""
|
| 43 |
+
try:
|
| 44 |
+
# 載入 SAM 分割模型
|
| 45 |
+
logger.info("正在載入 SAM 分割模型...")
|
| 46 |
+
self.sam_model = SamModel.from_pretrained("facebook/sam-vit-base")
|
| 47 |
+
self.sam_processor = SamProcessor.from_pretrained("facebook/sam-vit-base")
|
| 48 |
+
|
| 49 |
+
# 載入 DPT 深度估計模型
|
| 50 |
+
logger.info("正在載入 DPT 深度估計模型...")
|
| 51 |
+
self.dpt_model = pipeline("depth-estimation", model="Intel/dpt-large")
|
| 52 |
+
|
| 53 |
+
logger.info("SAM 和 DPT 模型載入完成!")
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"模型載入失敗: {str(e)}")
|
| 57 |
+
raise
|
| 58 |
+
|
| 59 |
+
def segment_food_area(self, image: Image.Image, bbox: List[float]) -> np.ndarray:
|
| 60 |
+
"""
|
| 61 |
+
使用 SAM 分割食物區域
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
image: PIL Image 物件
|
| 65 |
+
bbox: 邊界框 [x1, y1, x2, y2]
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
np.ndarray: 食物區域的遮罩
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
# 使用 SAM 進行分割
|
| 72 |
+
inputs = self.sam_processor(image, input_boxes=[bbox], return_tensors="pt")
|
| 73 |
+
|
| 74 |
+
with torch.no_grad():
|
| 75 |
+
outputs = self.sam_model(**inputs)
|
| 76 |
+
|
| 77 |
+
# 取得分割遮罩
|
| 78 |
+
masks_tensor = self.sam_processor.image_processor.post_process_masks(
|
| 79 |
+
outputs.pred_masks.sigmoid(),
|
| 80 |
+
inputs["original_sizes"],
|
| 81 |
+
inputs["reshaped_input_sizes"]
|
| 82 |
+
)[0]
|
| 83 |
+
|
| 84 |
+
# 選擇最大的遮罩
|
| 85 |
+
mask = masks_tensor[0].squeeze().cpu().numpy().astype(bool)
|
| 86 |
+
|
| 87 |
+
logger.info(f"SAM 分割完成,遮罩大小: {mask.shape}")
|
| 88 |
+
return mask
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"SAM 分割失敗: {str(e)}")
|
| 92 |
+
# 回傳預設遮罩
|
| 93 |
+
return np.ones((image.height, image.width), dtype=bool)
|
| 94 |
+
|
| 95 |
+
def estimate_depth(self, image: Image.Image) -> np.ndarray:
|
| 96 |
+
"""
|
| 97 |
+
使用 DPT 進行深度估計
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
image: PIL Image 物件
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
np.ndarray: 深度圖
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
# 使用 DPT 進行深度估計
|
| 107 |
+
depth_result = self.dpt_model(image)
|
| 108 |
+
depth_map = depth_result["depth"]
|
| 109 |
+
|
| 110 |
+
logger.info(f"DPT 深度估計完成,深度圖大小: {depth_map.shape}")
|
| 111 |
+
return np.array(depth_map)
|
| 112 |
+
|
| 113 |
+
except Exception as e:
|
| 114 |
+
logger.error(f"DPT 深度估計失敗: {str(e)}")
|
| 115 |
+
# 回傳預設深度圖
|
| 116 |
+
return np.ones((image.height, image.width))
|
| 117 |
+
|
| 118 |
+
def calculate_volume_and_weight(self,
|
| 119 |
+
mask: np.ndarray,
|
| 120 |
+
depth_map: np.ndarray,
|
| 121 |
+
food_name: str,
|
| 122 |
+
pixel_ratio: float) -> Tuple[float, float, float]:
|
| 123 |
+
"""
|
| 124 |
+
計算體積和重量
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
mask: 食物區域遮罩
|
| 128 |
+
depth_map: 深度圖
|
| 129 |
+
food_name: 食物名稱
|
| 130 |
+
pixel_ratio: 像素比例 (cm/pixel)
|
| 131 |
+
|
| 132 |
+
Returns:
|
| 133 |
+
Tuple[float, float, float]: (重量, 信心度, 誤差範圍)
|
| 134 |
+
"""
|
| 135 |
+
try:
|
| 136 |
+
# 計算食物區域的像素數量
|
| 137 |
+
food_pixels = np.sum(mask)
|
| 138 |
+
logger.info(f"重量計算開始 - 食物: {food_name}, 像素數量: {food_pixels}")
|
| 139 |
+
|
| 140 |
+
# 計算食物區域的平均深度
|
| 141 |
+
food_depth_values = depth_map[mask]
|
| 142 |
+
if len(food_depth_values) > 0:
|
| 143 |
+
food_depth = np.mean(food_depth_values)
|
| 144 |
+
depth_variance = np.var(food_depth_values)
|
| 145 |
+
else:
|
| 146 |
+
food_depth = 1.0
|
| 147 |
+
depth_variance = 0.0
|
| 148 |
+
|
| 149 |
+
logger.info(f"深度分析 - 平均深度: {food_depth:.4f}, 深度變異: {depth_variance:.4f}")
|
| 150 |
+
|
| 151 |
+
# 計算實際面積 (cm²)
|
| 152 |
+
area_cm2 = food_pixels * (pixel_ratio ** 2)
|
| 153 |
+
logger.info(f"面積計算 - 像素比例: {pixel_ratio:.4f}, 實際面積: {area_cm2:.2f} cm²")
|
| 154 |
+
|
| 155 |
+
# 動態調整形狀因子 (基於深度資訊)
|
| 156 |
+
if depth_variance > 0:
|
| 157 |
+
# 深度變異大,表示食物較立體
|
| 158 |
+
shape_factor = np.clip(0.6 + (depth_variance * 0.2), 0.3, 0.8)
|
| 159 |
+
else:
|
| 160 |
+
# 深度變異小,表示食物較扁平
|
| 161 |
+
shape_factor = np.clip(0.4 + (food_depth * 0.2), 0.2, 0.7)
|
| 162 |
+
|
| 163 |
+
logger.info(f"形狀因子 - 動態調整: {shape_factor:.4f}")
|
| 164 |
+
|
| 165 |
+
# 計算體積 (cm³)
|
| 166 |
+
volume_cm3 = shape_factor * (area_cm2 ** 1.5)
|
| 167 |
+
logger.info(f"體積計算 - 估算體積: {volume_cm3:.2f} cm³")
|
| 168 |
+
|
| 169 |
+
# 取得食物密度
|
| 170 |
+
density = self._get_food_density(food_name)
|
| 171 |
+
logger.info(f"密度查詢 - 食物: {food_name}, 密度: {density} g/cm³")
|
| 172 |
+
|
| 173 |
+
# 計算重量 (g)
|
| 174 |
+
weight = volume_cm3 * density
|
| 175 |
+
logger.info(f"重量計算 - 原始重量: {weight:.2f} g")
|
| 176 |
+
|
| 177 |
+
# 合理性檢查和調整
|
| 178 |
+
if weight > 2000: # 超過 2kg
|
| 179 |
+
logger.warning(f"重量 {weight:.2f}g 過高,進行調整")
|
| 180 |
+
weight = min(weight, 2000)
|
| 181 |
+
elif weight < 10: # 少於 10g
|
| 182 |
+
logger.warning(f"重量 {weight:.2f}g 過低,進行調整")
|
| 183 |
+
weight = max(weight, 10)
|
| 184 |
+
|
| 185 |
+
# 計算信心度和誤差範圍
|
| 186 |
+
confidence = self._calculate_confidence(pixel_ratio, depth_variance, food_pixels)
|
| 187 |
+
error_range = self._calculate_error_range(confidence)
|
| 188 |
+
|
| 189 |
+
logger.info(f"最終結果 - 重量: {weight:.2f}g, 信心度: {confidence:.2f}, 誤差範圍: ±{error_range*100:.1f}%")
|
| 190 |
+
|
| 191 |
+
return weight, confidence, error_range
|
| 192 |
+
|
| 193 |
+
except Exception as e:
|
| 194 |
+
logger.error(f"重量計算失敗: {str(e)}")
|
| 195 |
+
return 150.0, 0.3, 0.5 # 預設值
|
| 196 |
+
|
| 197 |
+
def _get_food_density(self, food_name: str) -> float:
|
| 198 |
+
"""根據食物名稱取得密度"""
|
| 199 |
+
food_name_lower = food_name.lower()
|
| 200 |
+
|
| 201 |
+
# 關鍵字匹配
|
| 202 |
+
for keyword, density in FOOD_DENSITY_TABLE.items():
|
| 203 |
+
if keyword in food_name_lower:
|
| 204 |
+
return density
|
| 205 |
+
|
| 206 |
+
return FOOD_DENSITY_TABLE["default"]
|
| 207 |
+
|
| 208 |
+
def _calculate_confidence(self, pixel_ratio: float, depth_variance: float, food_pixels: int) -> float:
|
| 209 |
+
"""計算信心度"""
|
| 210 |
+
# 基礎信心度
|
| 211 |
+
base_confidence = 0.6
|
| 212 |
+
|
| 213 |
+
# 像素比例影響 (比例越合理,信心度越高)
|
| 214 |
+
if 0.005 <= pixel_ratio <= 0.05:
|
| 215 |
+
base_confidence += 0.2
|
| 216 |
+
elif 0.001 <= pixel_ratio <= 0.1:
|
| 217 |
+
base_confidence += 0.1
|
| 218 |
+
|
| 219 |
+
# 深度變異影響 (適中的變異表示好的深度估計)
|
| 220 |
+
if 0.01 <= depth_variance <= 0.1:
|
| 221 |
+
base_confidence += 0.1
|
| 222 |
+
elif depth_variance > 0.5:
|
| 223 |
+
base_confidence -= 0.1
|
| 224 |
+
|
| 225 |
+
# 像素數量影響 (適中的像素數量表示好的分割)
|
| 226 |
+
if 1000 <= food_pixels <= 100000:
|
| 227 |
+
base_confidence += 0.1
|
| 228 |
+
elif food_pixels < 100:
|
| 229 |
+
base_confidence -= 0.2
|
| 230 |
+
|
| 231 |
+
return np.clip(base_confidence, 0.1, 0.95)
|
| 232 |
+
|
| 233 |
+
def _calculate_error_range(self, confidence: float) -> float:
|
| 234 |
+
"""根據信心度計算誤差範圍"""
|
| 235 |
+
# 信心度越高,誤差範圍越小
|
| 236 |
+
if confidence >= 0.8:
|
| 237 |
+
return 0.1 # ±10%
|
| 238 |
+
elif confidence >= 0.6:
|
| 239 |
+
return 0.2 # ±20%
|
| 240 |
+
elif confidence >= 0.4:
|
| 241 |
+
return 0.3 # ±30%
|
| 242 |
+
else:
|
| 243 |
+
return 0.5 # ±50%
|
| 244 |
+
|
| 245 |
+
# 全域服務實例
|
| 246 |
+
weight_calculation_service = WeightCalculationService()
|
| 247 |
+
|
| 248 |
+
def calculate_food_weight(image_bytes: bytes,
|
| 249 |
+
food_name: str,
|
| 250 |
+
pixel_ratio: float,
|
| 251 |
+
bbox: Optional[List[float]] = None) -> Dict[str, Any]:
|
| 252 |
+
"""
|
| 253 |
+
計算食物重量的主函數
|
| 254 |
+
|
| 255 |
+
Args:
|
| 256 |
+
image_bytes: 圖片二進位數據
|
| 257 |
+
food_name: 食物名稱
|
| 258 |
+
pixel_ratio: 像素比例
|
| 259 |
+
bbox: 可選的邊界框,如果沒有提供則使用整個圖片
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
Dict: 包含重量計算結果的字典
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
| 266 |
+
|
| 267 |
+
# 如果沒有提供邊界框,使用整個圖片
|
| 268 |
+
if bbox is None:
|
| 269 |
+
bbox = [0, 0, image.width, image.height]
|
| 270 |
+
|
| 271 |
+
logger.info(f"開始計算 {food_name} 的重量,使用邊界框: {bbox}")
|
| 272 |
+
|
| 273 |
+
# 1. 使用 SAM 分割食物區域
|
| 274 |
+
mask = weight_calculation_service.segment_food_area(image, bbox)
|
| 275 |
+
|
| 276 |
+
# 2. 使用 DPT 進行深度估計
|
| 277 |
+
depth_map = weight_calculation_service.estimate_depth(image)
|
| 278 |
+
|
| 279 |
+
# 3. 計算體積和重量
|
| 280 |
+
weight, confidence, error_range = weight_calculation_service.calculate_volume_and_weight(
|
| 281 |
+
mask, depth_map, food_name, pixel_ratio
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
# 4. 計算誤差範圍
|
| 285 |
+
weight_min = weight * (1 - error_range)
|
| 286 |
+
weight_max = weight * (1 + error_range)
|
| 287 |
+
|
| 288 |
+
return {
|
| 289 |
+
"food_name": food_name,
|
| 290 |
+
"estimated_weight": round(weight, 1),
|
| 291 |
+
"weight_confidence": confidence,
|
| 292 |
+
"weight_error_range": [round(weight_min, 1), round(weight_max, 1)],
|
| 293 |
+
"pixel_ratio": pixel_ratio,
|
| 294 |
+
"calculation_method": "SAM+DPT",
|
| 295 |
+
"success": True
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
except Exception as e:
|
| 299 |
+
logger.error(f"重量計算主流程失敗: {str(e)}")
|
| 300 |
+
return {
|
| 301 |
+
"food_name": food_name,
|
| 302 |
+
"estimated_weight": 150.0,
|
| 303 |
+
"weight_confidence": 0.3,
|
| 304 |
+
"weight_error_range": [100.0, 200.0],
|
| 305 |
+
"pixel_ratio": pixel_ratio,
|
| 306 |
+
"calculation_method": "SAM+DPT",
|
| 307 |
+
"success": False,
|
| 308 |
+
"error": str(e)
|
| 309 |
+
}
|
app/services/weight_estimation_service.py
CHANGED
|
@@ -6,6 +6,7 @@ from PIL import Image
|
|
| 6 |
import io
|
| 7 |
from typing import Dict, Any, List, Optional, Tuple
|
| 8 |
import torch
|
|
|
|
| 9 |
|
| 10 |
# 設置日誌
|
| 11 |
logging.basicConfig(level=logging.INFO)
|
|
@@ -55,10 +56,10 @@ class WeightEstimationService:
|
|
| 55 |
from transformers import pipeline
|
| 56 |
logger.info("正在載入 DPT 深度估計模型...")
|
| 57 |
self.dpt_model = pipeline("depth-estimation", model="Intel/dpt-large")
|
| 58 |
-
|
| 59 |
-
#
|
| 60 |
-
logger.info("
|
| 61 |
-
self.detection_model =
|
| 62 |
|
| 63 |
logger.info("所有模型載入完成!")
|
| 64 |
|
|
@@ -66,61 +67,51 @@ class WeightEstimationService:
|
|
| 66 |
logger.error(f"模型載入失敗: {str(e)}")
|
| 67 |
raise
|
| 68 |
|
| 69 |
-
def
|
| 70 |
-
"""
|
| 71 |
try:
|
| 72 |
-
# 使用 YOLOv5 偵測物件
|
| 73 |
results = self.detection_model(image)
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
label =
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
"
|
| 84 |
-
"confidence": confidence,
|
| 85 |
-
"bbox": result["box"]
|
| 86 |
})
|
| 87 |
-
|
| 88 |
-
if reference_objects:
|
| 89 |
-
# 選擇信心度最高的參考物
|
| 90 |
-
best_ref = max(reference_objects, key=lambda x: x["confidence"])
|
| 91 |
-
return best_ref
|
| 92 |
-
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
except Exception as e:
|
| 96 |
-
logger.warning(f"
|
| 97 |
-
return
|
| 98 |
|
| 99 |
-
def segment_food(self, image: Image.Image) -> np.ndarray:
|
| 100 |
-
"""使用 SAM
|
|
|
|
|
|
|
| 101 |
try:
|
| 102 |
-
# 使用 SAM
|
| 103 |
-
inputs = self.sam_processor(image, return_tensors="pt")
|
| 104 |
|
| 105 |
with torch.no_grad():
|
| 106 |
outputs = self.sam_model(**inputs)
|
| 107 |
|
| 108 |
# 取得分割遮罩
|
| 109 |
-
|
| 110 |
outputs.pred_masks.sigmoid(),
|
| 111 |
inputs["original_sizes"],
|
| 112 |
inputs["reshaped_input_sizes"]
|
| 113 |
)[0]
|
| 114 |
|
| 115 |
-
#
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
return mask
|
| 119 |
|
| 120 |
except Exception as e:
|
| 121 |
logger.error(f"食物分割失敗: {str(e)}")
|
| 122 |
-
|
| 123 |
-
return np.ones((image.height, image.width), dtype=bool)
|
| 124 |
|
| 125 |
def estimate_depth(self, image: Image.Image) -> np.ndarray:
|
| 126 |
"""使用 DPT 進行深度估計"""
|
|
@@ -154,7 +145,7 @@ class WeightEstimationService:
|
|
| 154 |
|
| 155 |
# 如果有參考���,進行尺寸校正
|
| 156 |
if reference_object:
|
| 157 |
-
ref_type = reference_object["type"
|
| 158 |
if ref_type in REFERENCE_OBJECTS:
|
| 159 |
ref_size = REFERENCE_OBJECTS[ref_type]
|
| 160 |
# 根據參考物尺寸校正體積
|
|
@@ -180,11 +171,15 @@ class WeightEstimationService:
|
|
| 180 |
error_range = 0.4 # ±40% 誤差
|
| 181 |
|
| 182 |
# 根據食物類型取得密度
|
| 183 |
-
density =
|
| 184 |
|
| 185 |
# 計算重量 (g)
|
| 186 |
weight = actual_volume * density
|
| 187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
return weight, confidence, error_range
|
| 189 |
|
| 190 |
except Exception as e:
|
|
@@ -200,7 +195,7 @@ class WeightEstimationService:
|
|
| 200 |
return FOOD_DENSITY_TABLE["rice"]
|
| 201 |
elif any(keyword in food_name_lower for keyword in ["noodle", "麵"]):
|
| 202 |
return FOOD_DENSITY_TABLE["noodles"]
|
| 203 |
-
elif any(keyword in food_name_lower for keyword in ["meat", "肉"]):
|
| 204 |
return FOOD_DENSITY_TABLE["meat"]
|
| 205 |
elif any(keyword in food_name_lower for keyword in ["vegetable", "菜"]):
|
| 206 |
return FOOD_DENSITY_TABLE["vegetables"]
|
|
@@ -210,87 +205,156 @@ class WeightEstimationService:
|
|
| 210 |
# 全域服務實例
|
| 211 |
weight_service = WeightEstimationService()
|
| 212 |
|
| 213 |
-
async def estimate_food_weight(image_bytes: bytes) -> Dict[str, Any]:
|
| 214 |
"""
|
| 215 |
-
整合食物辨識、重量估算與營養分析的主函數
|
| 216 |
"""
|
|
|
|
| 217 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
# 將 bytes 轉換為 PIL Image
|
| 219 |
-
image = Image.open(io.BytesIO(image_bytes))
|
| 220 |
-
|
| 221 |
-
# 1. 食物辨識(使用現有的 AI 服務)
|
| 222 |
-
from .ai_service import classify_food_image
|
| 223 |
-
food_name = classify_food_image(image_bytes)
|
| 224 |
|
| 225 |
-
|
| 226 |
-
|
| 227 |
|
| 228 |
-
#
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
depth_map = weight_service.estimate_depth(image)
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
-
#
|
| 235 |
-
|
| 236 |
-
food_mask, depth_map, food_name, reference_object
|
| 237 |
-
)
|
| 238 |
-
|
| 239 |
-
# 6. 查詢營養資訊
|
| 240 |
from .nutrition_api_service import fetch_nutrition_data
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
nutrition_info = {
|
| 245 |
-
"calories": 150,
|
| 246 |
-
"protein": 5,
|
| 247 |
-
"carbs": 25,
|
| 248 |
-
"fat": 3,
|
| 249 |
-
"fiber": 2
|
| 250 |
-
}
|
| 251 |
-
|
| 252 |
-
# 7. 根據重量調整營養素
|
| 253 |
-
weight_ratio = weight / 100 # 假設營養資訊是每100g的數據
|
| 254 |
-
adjusted_nutrition = {
|
| 255 |
-
key: value * weight_ratio
|
| 256 |
-
for key, value in nutrition_info.items()
|
| 257 |
-
}
|
| 258 |
-
|
| 259 |
-
# 8. 計算誤差範圍
|
| 260 |
-
error_min = weight * (1 - error_range)
|
| 261 |
-
error_max = weight * (1 + error_range)
|
| 262 |
|
| 263 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
if reference_object:
|
| 265 |
-
note
|
| 266 |
else:
|
| 267 |
-
note
|
| 268 |
|
| 269 |
-
|
| 270 |
-
"
|
| 271 |
-
"
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"nutrition": adjusted_nutrition,
|
| 275 |
-
"reference_object": reference_object["type"] if reference_object else None,
|
| 276 |
"note": note
|
| 277 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
except Exception as e:
|
| 280 |
-
logger.error(f"
|
| 281 |
-
#
|
| 282 |
-
|
| 283 |
-
"
|
| 284 |
-
"
|
| 285 |
-
"
|
| 286 |
-
"weight_error_range": [100.0, 200.0],
|
| 287 |
-
"nutrition": {
|
| 288 |
-
"calories": 150,
|
| 289 |
-
"protein": 5,
|
| 290 |
-
"carbs": 25,
|
| 291 |
-
"fat": 3,
|
| 292 |
-
"fiber": 2
|
| 293 |
-
},
|
| 294 |
"reference_object": None,
|
| 295 |
-
"note": "
|
| 296 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import io
|
| 7 |
from typing import Dict, Any, List, Optional, Tuple
|
| 8 |
import torch
|
| 9 |
+
from ultralytics import YOLO
|
| 10 |
|
| 11 |
# 設置日誌
|
| 12 |
logging.basicConfig(level=logging.INFO)
|
|
|
|
| 56 |
from transformers import pipeline
|
| 57 |
logger.info("正在載入 DPT 深度估計模型...")
|
| 58 |
self.dpt_model = pipeline("depth-estimation", model="Intel/dpt-large")
|
| 59 |
+
|
| 60 |
+
# 載入 YOLOv8 物件偵測模型(用於偵測參考物)
|
| 61 |
+
logger.info("正在載入 YOLOv8 物件偵測模型...")
|
| 62 |
+
self.detection_model = YOLO("yolov8n.pt") # 你可以改成 yolov5s.pt 或自訂模型
|
| 63 |
|
| 64 |
logger.info("所有模型載入完成!")
|
| 65 |
|
|
|
|
| 67 |
logger.error(f"模型載入失敗: {str(e)}")
|
| 68 |
raise
|
| 69 |
|
| 70 |
+
def detect_objects(self, image: Image.Image) -> List[Dict[str, Any]]:
|
| 71 |
+
"""使用 YOLOv8 偵測圖片中的所有物體"""
|
| 72 |
try:
|
|
|
|
| 73 |
results = self.detection_model(image)
|
| 74 |
+
detected_objects = []
|
| 75 |
+
for result in results[0].boxes.data.tolist():
|
| 76 |
+
x1, y1, x2, y2, conf, class_id = result
|
| 77 |
+
label = self.detection_model.model.names[int(class_id)].lower()
|
| 78 |
+
# 我們對所有高信度的物體都感興趣,除了明確的餐具
|
| 79 |
+
if conf > 0.4 and label not in ["spoon", "fork", "knife", "scissors"]:
|
| 80 |
+
detected_objects.append({
|
| 81 |
+
"label": label,
|
| 82 |
+
"bbox": [x1, y1, x2, y2],
|
| 83 |
+
"confidence": conf
|
|
|
|
|
|
|
| 84 |
})
|
| 85 |
+
return detected_objects
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
except Exception as e:
|
| 87 |
+
logger.warning(f"物件偵測失敗: {str(e)}")
|
| 88 |
+
return []
|
| 89 |
|
| 90 |
+
def segment_food(self, image: Image.Image, input_boxes: List[List[float]]) -> List[np.ndarray]:
|
| 91 |
+
"""使用 SAM 根據提供的邊界框分割食物區域"""
|
| 92 |
+
if not input_boxes:
|
| 93 |
+
return []
|
| 94 |
try:
|
| 95 |
+
# 使用 SAM 進行分割,並提供邊界框作為提示
|
| 96 |
+
inputs = self.sam_processor(image, input_boxes=[input_boxes], return_tensors="pt")
|
| 97 |
|
| 98 |
with torch.no_grad():
|
| 99 |
outputs = self.sam_model(**inputs)
|
| 100 |
|
| 101 |
# 取得分割遮罩
|
| 102 |
+
masks_tensor = self.sam_processor.image_processor.post_process_masks(
|
| 103 |
outputs.pred_masks.sigmoid(),
|
| 104 |
inputs["original_sizes"],
|
| 105 |
inputs["reshaped_input_sizes"]
|
| 106 |
)[0]
|
| 107 |
|
| 108 |
+
# 將 Tensor 轉換為 list of numpy arrays
|
| 109 |
+
masks = [m.squeeze().cpu().numpy().astype(bool) for m in masks_tensor]
|
| 110 |
+
return masks
|
|
|
|
| 111 |
|
| 112 |
except Exception as e:
|
| 113 |
logger.error(f"食物分割失敗: {str(e)}")
|
| 114 |
+
return []
|
|
|
|
| 115 |
|
| 116 |
def estimate_depth(self, image: Image.Image) -> np.ndarray:
|
| 117 |
"""使用 DPT 進行深度估計"""
|
|
|
|
| 145 |
|
| 146 |
# 如果有參考���,進行尺寸校正
|
| 147 |
if reference_object:
|
| 148 |
+
ref_type = reference_object["label"] # Changed from "type" to "label"
|
| 149 |
if ref_type in REFERENCE_OBJECTS:
|
| 150 |
ref_size = REFERENCE_OBJECTS[ref_type]
|
| 151 |
# 根據參考物尺寸校正體積
|
|
|
|
| 171 |
error_range = 0.4 # ±40% 誤差
|
| 172 |
|
| 173 |
# 根據食物類型取得密度
|
| 174 |
+
density = self.get_food_density(food_type)
|
| 175 |
|
| 176 |
# 計算重量 (g)
|
| 177 |
weight = actual_volume * density
|
| 178 |
|
| 179 |
+
# 對單一物件的重量做一個合理性檢查
|
| 180 |
+
if weight > 1500: # > 1.5kg
|
| 181 |
+
logger.warning(f"單一物件預估重量 {weight:.2f}g 過高,可能不準確。")
|
| 182 |
+
|
| 183 |
return weight, confidence, error_range
|
| 184 |
|
| 185 |
except Exception as e:
|
|
|
|
| 195 |
return FOOD_DENSITY_TABLE["rice"]
|
| 196 |
elif any(keyword in food_name_lower for keyword in ["noodle", "麵"]):
|
| 197 |
return FOOD_DENSITY_TABLE["noodles"]
|
| 198 |
+
elif any(keyword in food_name_lower for keyword in ["meat", "肉", "chicken", "pork", "beef", "lamb"]):
|
| 199 |
return FOOD_DENSITY_TABLE["meat"]
|
| 200 |
elif any(keyword in food_name_lower for keyword in ["vegetable", "菜"]):
|
| 201 |
return FOOD_DENSITY_TABLE["vegetables"]
|
|
|
|
| 205 |
# 全域服務實例
|
| 206 |
weight_service = WeightEstimationService()
|
| 207 |
|
| 208 |
+
async def estimate_food_weight(image_bytes: bytes, debug: bool = False) -> Dict[str, Any]:
|
| 209 |
"""
|
| 210 |
+
整合食物辨識、重量估算與營養分析的主函數 (YOLO + SAM 引導模式)
|
| 211 |
"""
|
| 212 |
+
debug_dir = None
|
| 213 |
try:
|
| 214 |
+
if debug:
|
| 215 |
+
import os
|
| 216 |
+
from datetime import datetime
|
| 217 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
| 218 |
+
debug_dir = os.path.join("debug_output", timestamp)
|
| 219 |
+
os.makedirs(debug_dir, exist_ok=True)
|
| 220 |
+
|
| 221 |
# 將 bytes 轉換為 PIL Image
|
| 222 |
+
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
+
if debug:
|
| 225 |
+
image.save(os.path.join(debug_dir, "00_original.jpg"))
|
| 226 |
|
| 227 |
+
# 1. 物件偵測 (YOLO),取得所有物件的邊界框
|
| 228 |
+
all_objects = weight_service.detect_objects(image)
|
| 229 |
+
|
| 230 |
+
if not all_objects:
|
| 231 |
+
note = "無法從圖片中偵測到任何物體。"
|
| 232 |
+
result = {"detected_foods": [], "total_estimated_weight": 0, "total_nutrition": {}, "note": note}
|
| 233 |
+
if debug: result["debug_output_path"] = debug_dir
|
| 234 |
+
return result
|
| 235 |
|
| 236 |
+
if debug:
|
| 237 |
+
from PIL import ImageDraw
|
| 238 |
+
debug_image = image.copy()
|
| 239 |
+
draw = ImageDraw.Draw(debug_image)
|
| 240 |
+
for obj in all_objects:
|
| 241 |
+
bbox = obj.get("bbox")
|
| 242 |
+
label = obj.get("label", "unknown")
|
| 243 |
+
draw.rectangle(bbox, outline="red", width=3)
|
| 244 |
+
draw.text((bbox[0], bbox[1]), label, fill="red")
|
| 245 |
+
debug_image.save(os.path.join(debug_dir, "01_detected_objects.jpg"))
|
| 246 |
+
|
| 247 |
+
# 2. 尋找參考物 (如餐盤、碗)
|
| 248 |
+
reference_objects = [obj for obj in all_objects if obj["label"] in ["plate", "bowl"]]
|
| 249 |
+
reference_object = max(reference_objects, key=lambda x: x["confidence"]) if reference_objects else None
|
| 250 |
+
|
| 251 |
+
# 3. 深度估計 (DPT),只需執行一次
|
| 252 |
depth_map = weight_service.estimate_depth(image)
|
| 253 |
+
if debug:
|
| 254 |
+
depth_for_save = (depth_map - np.min(depth_map)) / (np.max(depth_map) - np.min(depth_map) + 1e-6) * 255.0
|
| 255 |
+
Image.fromarray(depth_for_save.astype(np.uint8)).convert("L").save(os.path.join(debug_dir, "03_depth_map.png"))
|
| 256 |
|
| 257 |
+
# 載入相關服務
|
| 258 |
+
from .ai_service import classify_food_image
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
from .nutrition_api_service import fetch_nutrition_data
|
| 260 |
+
|
| 261 |
+
detected_foods = []
|
| 262 |
+
total_nutrition = {"calories": 0, "protein": 0, "carbs": 0, "fat": 0, "fiber": 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
|
| 264 |
+
# 4. 遍歷每個偵測到的物件 (YOLO Box)
|
| 265 |
+
food_objects = [obj for obj in all_objects if obj["label"] not in ["plate", "bowl"]]
|
| 266 |
+
|
| 267 |
+
for i, food_obj in enumerate(food_objects):
|
| 268 |
+
try:
|
| 269 |
+
# a. 使用物件的邊界框提示 SAM 進行精準分割
|
| 270 |
+
input_box = [food_obj["bbox"]]
|
| 271 |
+
masks = weight_service.segment_food(image, input_boxes=input_box)
|
| 272 |
+
if not masks: continue
|
| 273 |
+
|
| 274 |
+
# SAM 對於一個 prompt 可能回傳多個 mask,我們選最大的一個
|
| 275 |
+
mask = max(masks, key=lambda m: np.sum(m))
|
| 276 |
+
|
| 277 |
+
# b. 根據遮罩裁切出單一食物的圖片 (辨識用)
|
| 278 |
+
# (此部分邏輯與先前版本相同)
|
| 279 |
+
rows, cols = np.any(mask, axis=1), np.any(mask, axis=0)
|
| 280 |
+
if not np.any(rows) or not np.any(cols): continue
|
| 281 |
+
rmin, rmax = np.where(rows)[0][[0, -1]]
|
| 282 |
+
cmin, cmax = np.where(cols)[0][[0, -1]]
|
| 283 |
+
item_array = np.array(image); item_rgba = np.zeros((*item_array.shape[:2], 4), dtype=np.uint8)
|
| 284 |
+
item_rgba[:,:,:3] = item_array; item_rgba[:,:,3] = mask * 255
|
| 285 |
+
cropped_pil = Image.fromarray(item_rgba[rmin:rmax+1, cmin:cmax+1, :], 'RGBA')
|
| 286 |
+
buffer = io.BytesIO(); cropped_pil.save(buffer, format="PNG"); item_image_bytes = buffer.getvalue()
|
| 287 |
+
if debug:
|
| 288 |
+
cropped_pil.save(os.path.join(debug_dir, f"item_{i}_{food_obj['label']}_cropped.png"))
|
| 289 |
+
|
| 290 |
+
# c. 辨識食物種類 (使用更精準的食物辨識模型)
|
| 291 |
+
food_name = classify_food_image(item_image_bytes)
|
| 292 |
+
|
| 293 |
+
# d. 計算體積和重量
|
| 294 |
+
weight, confidence, error_range = weight_service.calculate_volume_and_weight(
|
| 295 |
+
mask, depth_map, food_name, reference_object
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# e. 查詢營養資訊
|
| 299 |
+
nutrition_info = fetch_nutrition_data(food_name)
|
| 300 |
+
if nutrition_info is None:
|
| 301 |
+
nutrition_info = {"calories": 0, "protein": 0, "carbs": 0, "fat": 0, "fiber": 0}
|
| 302 |
+
|
| 303 |
+
# f. 根據重量調整營養素
|
| 304 |
+
weight_ratio = weight / 100
|
| 305 |
+
adjusted_nutrition = {k: v * weight_ratio for k, v in nutrition_info.items()}
|
| 306 |
+
|
| 307 |
+
# g. 累加總營養
|
| 308 |
+
for key in total_nutrition: total_nutrition[key] += adjusted_nutrition.get(key, 0)
|
| 309 |
+
|
| 310 |
+
# h. 儲存單項食物結果
|
| 311 |
+
detected_foods.append({
|
| 312 |
+
"food_name": food_name,
|
| 313 |
+
"estimated_weight": round(weight, 1),
|
| 314 |
+
"nutrition": {k: round(v, 1) for k, v in adjusted_nutrition.items()}
|
| 315 |
+
})
|
| 316 |
+
except Exception as item_e:
|
| 317 |
+
logger.error(f"處理物件 '{food_obj['label']}' 時失敗: {str(item_e)}")
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
# 5. 生成備註
|
| 321 |
+
note = f"已使用 YOLO+SAM 模型成功分析 {len(detected_foods)} 項食物。"
|
| 322 |
if reference_object:
|
| 323 |
+
note += f" 檢測到參考物:{reference_object['label']},準確度較高。"
|
| 324 |
else:
|
| 325 |
+
note += " 未檢測到參考物,重量為估算值,結果僅供參考。"
|
| 326 |
|
| 327 |
+
result = {
|
| 328 |
+
"detected_foods": detected_foods,
|
| 329 |
+
"total_estimated_weight": round(sum(item['estimated_weight'] for item in detected_foods), 1),
|
| 330 |
+
"total_nutrition": {k: round(v, 1) for k, v in total_nutrition.items()},
|
| 331 |
+
"reference_object": reference_object["label"] if reference_object else None,
|
|
|
|
|
|
|
| 332 |
"note": note
|
| 333 |
}
|
| 334 |
+
if debug:
|
| 335 |
+
# 儲存最終分割圖
|
| 336 |
+
overlay_img = image.copy()
|
| 337 |
+
overlay_array = np.array(overlay_img)
|
| 338 |
+
# Find all masks again to draw
|
| 339 |
+
all_food_boxes = [obj['bbox'] for obj in food_objects]
|
| 340 |
+
all_masks = weight_service.segment_food(image, input_boxes=all_food_boxes)
|
| 341 |
+
for mask in all_masks:
|
| 342 |
+
color = np.random.randint(0, 255, size=3, dtype=np.uint8)
|
| 343 |
+
overlay_array[mask] = (overlay_array[mask] * 0.5 + color * 0.5).astype(np.uint8)
|
| 344 |
+
Image.fromarray(overlay_array).save(os.path.join(debug_dir, "02_final_segmentation.jpg"))
|
| 345 |
+
result["debug_output_path"] = debug_dir
|
| 346 |
+
return result
|
| 347 |
|
| 348 |
except Exception as e:
|
| 349 |
+
logger.error(f"多食物重量估算主流程失敗: {str(e)}")
|
| 350 |
+
# 回傳包含錯誤訊息的標準結構
|
| 351 |
+
result = {
|
| 352 |
+
"detected_foods": [],
|
| 353 |
+
"total_estimated_weight": 0,
|
| 354 |
+
"total_nutrition": {},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
"reference_object": None,
|
| 356 |
+
"note": f"分析失敗: {str(e)}"
|
| 357 |
+
}
|
| 358 |
+
if debug and debug_dir:
|
| 359 |
+
result["debug_output_path"] = debug_dir
|
| 360 |
+
return result
|