Spaces:
Running
Running
# 檔案路徑: app/services/weight_calculation_service.py | |
import logging | |
import numpy as np | |
from PIL import Image | |
import io | |
from typing import Dict, Any, List, Optional, Tuple | |
import torch | |
from transformers import SamModel, SamProcessor, pipeline | |
# 設置日誌 | |
logging.basicConfig(level=logging.INFO) | |
logger = logging.getLogger(__name__) | |
# 食物密度表 (g/cm³) - 常見食物的平均密度 | |
FOOD_DENSITY_TABLE = { | |
"rice": 0.8, # 米飯 | |
"fried_rice": 0.7, # 炒飯 | |
"noodles": 0.6, # 麵條 | |
"bread": 0.3, # 麵包 | |
"meat": 1.0, # 肉類 | |
"fish": 1.1, # 魚類 | |
"vegetables": 0.4, # 蔬菜 | |
"fruits": 0.8, # 水果 | |
"soup": 1.0, # 湯類 | |
"sushi": 0.9, # 壽司 | |
"pizza": 0.6, # 披薩 | |
"hamburger": 0.7, # 漢堡 | |
"salad": 0.3, # 沙拉 | |
"default": 0.8 # 預設密度 | |
} | |
class WeightCalculationService: | |
def __init__(self): | |
"""初始化重量計算服務""" | |
self.sam_model = None | |
self.sam_processor = None | |
self.dpt_model = None | |
self._load_models() | |
def _load_models(self): | |
"""載入 SAM 和 DPT 模型""" | |
try: | |
# 載入 SAM 分割模型 | |
logger.info("正在載入 SAM 分割模型...") | |
self.sam_model = SamModel.from_pretrained("facebook/sam-vit-base") | |
self.sam_processor = SamProcessor.from_pretrained("facebook/sam-vit-base") | |
# 載入 DPT 深度估計模型 | |
logger.info("正在載入 DPT 深度估計模型...") | |
self.dpt_model = pipeline("depth-estimation", model="Intel/dpt-large") | |
logger.info("SAM 和 DPT 模型載入完成!") | |
except Exception as e: | |
logger.error(f"模型載入失敗: {str(e)}") | |
raise | |
def segment_food_area(self, image: Image.Image, bbox: List[float]) -> np.ndarray: | |
""" | |
使用 SAM 分割食物區域 | |
Args: | |
image: PIL Image 物件 | |
bbox: 邊界框 [x1, y1, x2, y2] | |
Returns: | |
np.ndarray: 食物區域的遮罩 | |
""" | |
try: | |
# 使用 SAM 進行分割 | |
inputs = self.sam_processor(image, input_boxes=[bbox], return_tensors="pt") | |
with torch.no_grad(): | |
outputs = self.sam_model(**inputs) | |
# 取得分割遮罩 | |
masks_tensor = self.sam_processor.image_processor.post_process_masks( | |
outputs.pred_masks.sigmoid(), | |
inputs["original_sizes"], | |
inputs["reshaped_input_sizes"] | |
)[0] | |
# 選擇最大的遮罩 | |
mask = masks_tensor[0].squeeze().cpu().numpy().astype(bool) | |
logger.info(f"SAM 分割完成,遮罩大小: {mask.shape}") | |
return mask | |
except Exception as e: | |
logger.error(f"SAM 分割失敗: {str(e)}") | |
# 回傳預設遮罩 | |
return np.ones((image.height, image.width), dtype=bool) | |
def estimate_depth(self, image: Image.Image) -> np.ndarray: | |
""" | |
使用 DPT 進行深度估計 | |
Args: | |
image: PIL Image 物件 | |
Returns: | |
np.ndarray: 深度圖 | |
""" | |
try: | |
# 使用 DPT 進行深度估計 | |
depth_result = self.dpt_model(image) | |
depth_map = depth_result["depth"] | |
logger.info(f"DPT 深度估計完成,深度圖大小: {depth_map.shape}") | |
return np.array(depth_map) | |
except Exception as e: | |
logger.error(f"DPT 深度估計失敗: {str(e)}") | |
# 回傳預設深度圖 | |
return np.ones((image.height, image.width)) | |
def calculate_volume_and_weight(self, | |
mask: np.ndarray, | |
depth_map: np.ndarray, | |
food_name: str, | |
pixel_ratio: float) -> Tuple[float, float, float]: | |
""" | |
計算體積和重量 | |
Args: | |
mask: 食物區域遮罩 | |
depth_map: 深度圖 | |
food_name: 食物名稱 | |
pixel_ratio: 像素比例 (cm/pixel) | |
Returns: | |
Tuple[float, float, float]: (重量, 信心度, 誤差範圍) | |
""" | |
try: | |
# 計算食物區域的像素數量 | |
food_pixels = np.sum(mask) | |
logger.info(f"重量計算開始 - 食物: {food_name}, 像素數量: {food_pixels}") | |
# 計算食物區域的平均深度 | |
food_depth_values = depth_map[mask] | |
if len(food_depth_values) > 0: | |
food_depth = np.mean(food_depth_values) | |
depth_variance = np.var(food_depth_values) | |
else: | |
food_depth = 1.0 | |
depth_variance = 0.0 | |
logger.info(f"深度分析 - 平均深度: {food_depth:.4f}, 深度變異: {depth_variance:.4f}") | |
# 計算實際面積 (cm²) | |
area_cm2 = food_pixels * (pixel_ratio ** 2) | |
logger.info(f"面積計算 - 像素比例: {pixel_ratio:.4f}, 實際面積: {area_cm2:.2f} cm²") | |
# 動態調整形狀因子 (基於深度資訊) | |
if depth_variance > 0: | |
# 深度變異大,表示食物較立體 | |
shape_factor = np.clip(0.6 + (depth_variance * 0.2), 0.3, 0.8) | |
else: | |
# 深度變異小,表示食物較扁平 | |
shape_factor = np.clip(0.4 + (food_depth * 0.2), 0.2, 0.7) | |
logger.info(f"形狀因子 - 動態調整: {shape_factor:.4f}") | |
# 計算體積 (cm³) | |
volume_cm3 = shape_factor * (area_cm2 ** 1.5) | |
logger.info(f"體積計算 - 估算體積: {volume_cm3:.2f} cm³") | |
# 取得食物密度 | |
density = self._get_food_density(food_name) | |
logger.info(f"密度查詢 - 食物: {food_name}, 密度: {density} g/cm³") | |
# 計算重量 (g) | |
weight = volume_cm3 * density | |
logger.info(f"重量計算 - 原始重量: {weight:.2f} g") | |
# 合理性檢查和調整 | |
if weight > 2000: # 超過 2kg | |
logger.warning(f"重量 {weight:.2f}g 過高,進行調整") | |
weight = min(weight, 2000) | |
elif weight < 10: # 少於 10g | |
logger.warning(f"重量 {weight:.2f}g 過低,進行調整") | |
weight = max(weight, 10) | |
# 計算信心度和誤差範圍 | |
confidence = self._calculate_confidence(pixel_ratio, depth_variance, food_pixels) | |
error_range = self._calculate_error_range(confidence) | |
logger.info(f"最終結果 - 重量: {weight:.2f}g, 信心度: {confidence:.2f}, 誤差範圍: ±{error_range*100:.1f}%") | |
return weight, confidence, error_range | |
except Exception as e: | |
logger.error(f"重量計算失敗: {str(e)}") | |
return 150.0, 0.3, 0.5 # 預設值 | |
def _get_food_density(self, food_name: str) -> float: | |
"""根據食物名稱取得密度""" | |
food_name_lower = food_name.lower() | |
# 關鍵字匹配 | |
for keyword, density in FOOD_DENSITY_TABLE.items(): | |
if keyword in food_name_lower: | |
return density | |
return FOOD_DENSITY_TABLE["default"] | |
def _calculate_confidence(self, pixel_ratio: float, depth_variance: float, food_pixels: int) -> float: | |
"""計算信心度""" | |
# 基礎信心度 | |
base_confidence = 0.6 | |
# 像素比例影響 (比例越合理,信心度越高) | |
if 0.005 <= pixel_ratio <= 0.05: | |
base_confidence += 0.2 | |
elif 0.001 <= pixel_ratio <= 0.1: | |
base_confidence += 0.1 | |
# 深度變異影響 (適中的變異表示好的深度估計) | |
if 0.01 <= depth_variance <= 0.1: | |
base_confidence += 0.1 | |
elif depth_variance > 0.5: | |
base_confidence -= 0.1 | |
# 像素數量影響 (適中的像素數量表示好的分割) | |
if 1000 <= food_pixels <= 100000: | |
base_confidence += 0.1 | |
elif food_pixels < 100: | |
base_confidence -= 0.2 | |
return np.clip(base_confidence, 0.1, 0.95) | |
def _calculate_error_range(self, confidence: float) -> float: | |
"""根據信心度計算誤差範圍""" | |
# 信心度越高,誤差範圍越小 | |
if confidence >= 0.8: | |
return 0.1 # ±10% | |
elif confidence >= 0.6: | |
return 0.2 # ±20% | |
elif confidence >= 0.4: | |
return 0.3 # ±30% | |
else: | |
return 0.5 # ±50% | |
# 全域服務實例 | |
weight_calculation_service = WeightCalculationService() | |
def calculate_food_weight(image_bytes: bytes, | |
food_name: str, | |
pixel_ratio: float, | |
bbox: Optional[List[float]] = None) -> Dict[str, Any]: | |
""" | |
計算食物重量的主函數 | |
Args: | |
image_bytes: 圖片二進位數據 | |
food_name: 食物名稱 | |
pixel_ratio: 像素比例 | |
bbox: 可選的邊界框,如果沒有提供則使用整個圖片 | |
Returns: | |
Dict: 包含重量計算結果的字典 | |
""" | |
try: | |
image = Image.open(io.BytesIO(image_bytes)).convert("RGB") | |
# 如果沒有提供邊界框,使用整個圖片 | |
if bbox is None: | |
bbox = [0, 0, image.width, image.height] | |
logger.info(f"開始計算 {food_name} 的重量,使用邊界框: {bbox}") | |
# 1. 使用 SAM 分割食物區域 | |
mask = weight_calculation_service.segment_food_area(image, bbox) | |
# 2. 使用 DPT 進行深度估計 | |
depth_map = weight_calculation_service.estimate_depth(image) | |
# 3. 計算體積和重量 | |
weight, confidence, error_range = weight_calculation_service.calculate_volume_and_weight( | |
mask, depth_map, food_name, pixel_ratio | |
) | |
# 4. 計算誤差範圍 | |
weight_min = weight * (1 - error_range) | |
weight_max = weight * (1 + error_range) | |
return { | |
"food_name": food_name, | |
"estimated_weight": round(weight, 1), | |
"weight_confidence": confidence, | |
"weight_error_range": [round(weight_min, 1), round(weight_max, 1)], | |
"pixel_ratio": pixel_ratio, | |
"calculation_method": "SAM+DPT", | |
"success": True | |
} | |
except Exception as e: | |
logger.error(f"重量計算主流程失敗: {str(e)}") | |
return { | |
"food_name": food_name, | |
"estimated_weight": 150.0, | |
"weight_confidence": 0.3, | |
"weight_error_range": [100.0, 200.0], | |
"pixel_ratio": pixel_ratio, | |
"calculation_method": "SAM+DPT", | |
"success": False, | |
"error": str(e) | |
} |