import cv2 import numpy as np from typing import Dict, Any, Optional class MicrowaveDishAnalyzer: """ Estimates food dish volume from top-down camera images and dish height, and cross-validates physical volume against Edamam AI mass estimates. """ # Constant scale ratio: Centimeters per Pixel. # TODO : Replace this value once your camera calibration is complete. CM_PER_PIXEL: float = 0.05 # Example: 1 pixel = 0.05 cm def __init__(self, cm_per_pixel: Optional[float] = None): if cm_per_pixel is not None: self.cm_per_pixel = cm_per_pixel else: self.cm_per_pixel = self.CM_PER_PIXEL def calculate_surface_area_cm2(self, image_path: str) -> float: """ Segments the food/dish from the background and returns surface area in cm². """ image = cv2.imread(image_path) if image is None: raise FileNotFoundError(f"Image could not be loaded from path: {image_path}") # 1. Convert to grayscale & blur to reduce noise gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) # 2. Otsu thresholding to segment foreground (dish) from background (turntable) _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 3. Find contours contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return 0.0 # 4. Assume the largest contour corresponds to the dish/food area largest_contour = max(contours, key=cv2.contourArea) area_pixels = cv2.contourArea(largest_contour) # 5. Convert pixels² to cm² using scale ratio squared area_cm2 = area_pixels * (self.cm_per_pixel ** 2) return float(area_cm2) @staticmethod def _get_shape_factor(food_label: str) -> float: """ Selects geometric correction factor (k_shape) based on container/food shape: - Bowls/Soups: ~0.60 (paraboloid) - Drinks/Mugs: ~0.95 (cylinder) - Flat plates/solid foods: ~0.85 (truncated cone / disk) """ label = food_label.lower() if any(w in label for w in ["soup", "chili", "stew", "curry", "bowl"]): return 0.60 elif any(w in label for w in ["coffee", "tea", "milk", "water", "beverage", "mug"]): return 0.95 elif any(w in label for w in ["bread", "cake", "muffin"]): return 0.80 return 0.85 # Default factor for plated meals def estimate_volume( self, image_path: str, height_cm: float, food_label: str = "" ) -> Dict[str, float]: """ Computes total physical volume in cm³ (mL). Volume = Area (cm²) * Height (cm) * Shape Factor """ area_cm2 = self.calculate_surface_area_cm2(image_path) k_shape = self._get_shape_factor(food_label) volume_cm3 = area_cm2 * height_cm * k_shape return { "surface_area_cm2": round(area_cm2, 2), "height_cm": round(height_cm, 2), "shape_factor": k_shape, "volume_cm3": round(volume_cm3, 2), } def reconcile_mass( self, edamam_mass_g: float, volume_cm3: float, food_label: str = "" ) -> Dict[str, Any]: """ Cross-validates Edamam's visual mass against physical volume using expected density. Returns the most physically accurate mass estimate in grams. """ if volume_cm3 <= 0: return { "final_mass_g": edamam_mass_g, "status": "unvalidated_zero_volume", "calculated_density": None, } calculated_density = edamam_mass_g / volume_cm3 label = food_label.lower() # Expected food densities (g/cm³) if any(w in label for w in ["bread", "popcorn", "cake"]): expected_density = 0.35 elif any(w in label for w in ["soup", "beverage", "water", "milk"]): expected_density = 1.0 else: expected_density = 0.92 # Average cooked meal (water + fats + carbs) # Plausibility bounds (±35% variance around expected density) min_density = expected_density * 0.65 max_density = expected_density * 1.35 if min_density <= calculated_density <= max_density: # Edamam estimate is physically realistic final_mass = edamam_mass_g status = "validated_edamam_mass" else: # Edamam misjudged scale — fallback to Volume * Expected Density final_mass = volume_cm3 * expected_density status = "reconciled_via_volume_density" return { "final_mass_g": round(final_mass, 2), "raw_edamam_mass_g": edamam_mass_g, "calculated_density_g_cm3": round(calculated_density, 3), "expected_density_g_cm3": expected_density, "status": status, }