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
|