Edamam API and dish volume estimation
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user