yuting111222 commited on
Commit
a608ddf
·
1 Parent(s): f9b12dd

Update health assistant minimal with new services and improvements

Browse files
__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
- return {"food_name": "測試食物", "nutrition_info": {"calories": 100}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return WeightEstimationResponse(
51
- food_type="測試食物",
52
- estimated_weight=150.0,
53
- weight_confidence=0.85,
54
- weight_error_range=[130.0, 170.0],
55
- nutrition={"calories": 100, "protein": 5, "fat": 2, "carbs": 15},
56
- reference_object="硬幣",
57
- note="測試重量估算結果"
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
- "weight_estimation": "available",
70
- "nutrition_api": "available"
 
 
 
 
 
 
 
 
 
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", "DEMO_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 = pipeline("object-detection", model="ultralytics/yolov5")
62
 
63
  logger.info("所有模型載入完成!")
64
 
@@ -66,61 +67,51 @@ class WeightEstimationService:
66
  logger.error(f"模型載入失敗: {str(e)}")
67
  raise
68
 
69
- def detect_reference_objects(self, image: Image.Image) -> Optional[Dict[str, Any]]:
70
- """偵測圖片中的參考物(餐盤、餐具等)"""
71
  try:
72
- # 使用 YOLOv5 偵測物件
73
  results = self.detection_model(image)
74
-
75
- reference_objects = []
76
- for result in results:
77
- label = result["label"].lower()
78
- confidence = result["score"]
79
-
80
- # 檢查是否為參考物
81
- if any(ref in label for ref in ["plate", "bowl", "spoon", "fork", "knife"]):
82
- reference_objects.append({
83
- "type": label,
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"參考物偵測失敗: {str(e)}")
97
- return None
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
- masks = self.sam_processor.image_processor.post_process_masks(
110
  outputs.pred_masks.sigmoid(),
111
  inputs["original_sizes"],
112
  inputs["reshaped_input_sizes"]
113
  )[0]
114
 
115
- # 選擇最大的遮罩作為食物區域
116
- mask = masks[0].numpy() # 簡化處理,選擇第一個遮罩
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 = FOOD_DENSITY_TABLE.get(food_type.lower(), FOOD_DENSITY_TABLE["default"])
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
- # 2. 偵測參考物
226
- reference_object = weight_service.detect_reference_objects(image)
227
 
228
- # 3. 食物分割
229
- food_mask = weight_service.segment_food(image)
 
 
 
 
 
 
230
 
231
- # 4. 深度估計
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  depth_map = weight_service.estimate_depth(image)
 
 
 
233
 
234
- # 5. 計算體積和重量
235
- weight, confidence, error_range = weight_service.calculate_volume_and_weight(
236
- food_mask, depth_map, food_name, reference_object
237
- )
238
-
239
- # 6. 查詢營養資訊
240
  from .nutrition_api_service import fetch_nutrition_data
241
- nutrition_info = fetch_nutrition_data(food_name)
242
-
243
- if nutrition_info is None:
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
- # 9. 生成備註
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  if reference_object:
265
- note = f"檢測到參考物:{reference_object['type']},準確度較高"
266
  else:
267
- note = "未檢測到參考物,重量為估算值,僅供參考"
268
 
269
- return {
270
- "food_type": food_name,
271
- "estimated_weight": round(weight, 1),
272
- "weight_confidence": round(confidence, 2),
273
- "weight_error_range": [round(error_min, 1), round(error_max, 1)],
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"重量估算失敗: {str(e)}")
281
- # 回傳預設結果
282
- return {
283
- "food_type": "Unknown",
284
- "estimated_weight": 150.0,
285
- "weight_confidence": 0.3,
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