File size: 14,567 Bytes
224775c dcb17ba cc56fad dcb17ba 224775c 1f41b25 6bf5da2 1f41b25 6bf5da2 dcb17ba 6bf5da2 dcb17ba 6dce8e5 dcb17ba 6bf5da2 dcb17ba 6bf5da2 dcb17ba fab9fa8 b5eef15 dcb17ba c043832 dcb17ba 6bf5da2 dcb17ba 6bf5da2 dcb17ba 6bf5da2 dcb17ba b5eef15 dcb17ba 6bf5da2 cc56fad dcb17ba cc56fad dcb17ba cc56fad dcb17ba cc56fad dcb17ba 6bf5da2 c255bc1 6bf5da2 fab9fa8 6bf5da2 b5eef15 dcb17ba 6bf5da2 b5eef15 9049a8f b5eef15 9049a8f b5eef15 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 | ---
tags:
- image-to-text
- food
- calorie-estimation
- macronutrient-estimation
- siglip2
- nutrition5k
license: mit
base_model: google/siglip2-base-patch16-224
datasets:
- pinkieseb/nutrition_dataset
- aryachakraborty/Food_Calorie_Dataset
metrics:
- mae
---
# CalorieK β food-photo calorie & macro estimation
A single RGB food photo in, five numbers out: **calories (kcal), mass (g), protein (g), carbohydrate (g), fat (g)**.
CalorieK is a SigLIP2-base vision tower with a small regression head, fine-tuned end-to-end and evaluated
under a strict, leakage-audited Nutrition5k protocol.

## Headline result β locked Nutrition5k test set (606 dishes, official test split, never seen)
| Metric | CalorieK | CalorieCLIP (same dishes) |
| --- | --- | --- |
| Calorie MAE | **56.3 kcal** | 126.4 kcal |
| Median absolute error | **30.0 kcal** | 106.2 kcal |
| Within Β±50 kcal | **65.0%** | 20.3% |
| Within Β±100 kcal | **84.3%** | 47.2% |
| Bias | **β19.5 kcal** | +97.5 kcal |
| Invalid predictions | 0 / 606 | 0 / 606 |
CalorieK more than halves CalorieCLIP's calorie MAE and nearly doubles its within-Β±100 hit rate on the
same dishes. Every test image is a deterministic central frame from the official Nutrition5k camera-A
side-angle video of an official _test-split_ dish β selected by dish ID, never by image quality.
Evaluation is dish-level (`physical_meal_id`).
> Two of the 608 official test dishes carry corrupt ground-truth labels (a single 7,974 g "olives"
> ingredient gives one dish 876 g of fat in a 159 g dish, and another an 8 kg mass) and are excluded
> as physically impossible (calories > 2000). Including them, CalorieK's calorie MAE is 86.2 kcal
> (RMSE 533, driven almost entirely by those two dishes); excluding them RMSE is 94.
### Macronutrient accuracy (same 606 dishes)
| Task | MAE | PMAE |
| --- | --- | --- |
| Mass | 55.3 g | 30.1% |
| Protein | 6.2 g | 37.4% |
| Carbohydrate | 6.3 g | 35.3% |
| Fat | 5.0 g | 43.5% |
## Comparison with open VLMs (paired 303-dish subset, seed 17)
Each model answered the same calorie question on the same dishes, scored against the official
Nutrition5k calorie labels.

| Model | Params | MAE (kcal) | Median AE | Within Β±100 kcal | Bias |
| --- | --- | --- | --- | --- | --- |
| **CalorieK (ours)** | 93.7M | **54.7** | **27.8** | **82.5%** | β16.3 |
| Qwen3.5-9B (Q8_0)β‘ | 9B | 82.6 | 55.7 | 68.6% | β14.0 |
| CalorieCLIP (public) | 151M | 126.7 | 106.4 | 46.9% | +101.0 |
| MiniMax M3 (reasoning)Β§ | 428B (MoE) | 130.6 | 94.7 | 52.8% | +83.7 |
β‘ Qwen3.5-9B: 8-bit GGUF (Q8_0) via llama.cpp, 4k context, thinking off.
Β§ MiniMax M3: hosted reasoning model, chain-of-thought enabled β extensive per-dish reasoning did not
help; it lands last with a strong positive bias. Reasoning does not fix calibration.
## Out-of-distribution comparison on a fresh MM-Food-100K sample
To stress-test both models outside the Nutrition5k training distribution, we built a second,
**disjoint** OOD benchmark from the public MM-Food-100K dataset
([`Humanbased-AI/MM-Food-100K`](https://huggingface.co/datasets/Humanbased-AI/MM-Food-100K)).
500 fresh images were downloaded from the dataset's original `b18a.io` URLs β none of them appear in
the prior 500-dish OOD subset (which used a different scratchpad cache), and none appear in
CalorieK's training data. The food-type mix was deliberately flipped from the prior test
(238/177/42/42/1 Homemade/Restaurant/Raw/Packaged/Other β 100/0/200/199/0 Homemade/Raw/Packaged/Other),
so this benchmark targets the food categories the prior test barely touched.
All per-image labels come straight from the published MM-Food-100K CSV (`calories_kcal`,
`protein_g`, `carbohydrate_g`, `fat_g` per row) β no per-class nutrition lookup, no fabrication.
Mean target 233 kcal, range 2β2500 kcal. Seed 137 for the sampling. Full reproducibility:
`scripts/sample_ood_v2.py` β `scripts/download_ood_v2.py` β `scripts/build_ood_v2_manifest.py`
β `scripts/compare_ood.py`.



| Metric (n = 499) | CalorieK | CalorieCLIP | Ξ (CK β CC) |
| --- | --- | --- | --- |
| **Calorie MAE** | **181.2 kcal** | 225.3 kcal | β44.1 |
| Median AE | **155.5** | 194.6 | β39.1 |
| RMSE | **272.6** | 304.8 | β32.2 |
| **Within Β±50 kcal** | **19.2%** | 9.8% | +9.4 pp |
| **Within Β±100 kcal** | **34.9%** | 22.8% | +12.0 pp |
| Bias | **+82.3** | +156.4 | β74.1 |
| Invalid predictions | 0 / 499 | 0 / 499 | β |
**Per food-type (where the new mix actually shifts the failure modes):**
| Food type | n | CalorieK MAE | CalorieK w/Β±100 | CalorieCLIP MAE | CalorieCLIP w/Β±100 |
| --- | ---:| ---:| ---:| ---:| ---:|
| Homemade food | 100 | **171** | **37%** | 251 | 23% |
| Packaged food | 199 | **123** | **59%** | 204 | 27% |
| Raw vegetables & fruits | 200 | 244 | 10% | **234** | **19%** |
CalorieK wins on Homemade and Packaged by 80 and 81 kcal MAE respectively, and almost doubles
CalorieCLIP's within-Β±100 hit rate on those categories. On Raw vegetables & fruit both models
fall apart (MAE 234β244 on a mean target of ~150 kcal, within-Β±100 under 20%) β this is the
long-tail small-portion regime that neither model handles, and the scatter shows both regress to
the 200β400 kcal range even for items like a single 62 kcal "Orange Slices" or 68 kcal "Fresh
Peaches." Four outliers in the 1,800β2,500 kcal range (e.g. "Pork Belly" labelled 2500 kcal and
categorised as Raw vegetables) drag both models' overall MAE up; these are the rare cases where
the MM-Food-100K ground truth is implausible (a single 2,000 g "Pork Belly" portion is
plausible, but its `food_type` labelling is clearly wrong). CalorieCLIP is also systematically
over-confident: it predicts 760 kcal on a 68 kcal peach and 771 kcal on a 150 kcal dairy drink,
a +156 kcal bias against CalorieK's +82.
**Caveats on this benchmark.** (1) MM-Food-100K is the *same source dataset* as the prior OOD
test, just a fresh, disjoint sample β it is not a wholly new dataset. We could not find a third,
truly-distinct, downloadable per-image-calorie food dataset (OmniFood8K is author-only via
Google Drive; CGMacros is CGM-centric; the only other plausible option, Food-101, has class
labels not per-image calories and would have required a fabricated per-class nutrition table,
which violates our no-fabrication rule). (2) MM-Food-100K labels are AI/crowd estimates, not
weighed ground truth β a Β±10β20% label noise floor is expected. (3) The 2,500 kcal outliers
are clearly label noise and inflate both models' MAE.
Full per-prediction CSV: `outputs/compare_ood_v2/predictions.csv`. Aggregate metrics:
`outputs/compare_ood_v2/report.json`. Raw downloads: `data/mmfood100k_v2/images/`.
## Comparison with open-source calorie VLMs (same protocol)
Beyond CalorieCLIP, we ran the published checkpoints of every other public calorie VLM we could
get to run end-to-end on a single RGB photo β Food-R1 (8-bit), Boba 0.8B (Q4_K_M GGUF),
DPF-Nutrition, and OmniFood FAFM β under the same locked protocol and the same MM-Food-100K v2
OOD sample, so the numbers are directly comparable. The full per-model report (including the
per-task macro/mass errors) lives in `outputs/external_model_comparison.md`.

### Locked Nutrition5k test (606 dishes; 2 corrupt-label dishes excluded)
| Model | Checkpoint | n | Calorie MAE | PMAE |
| --- | --- | ---:| ---:| ---:|
| **CalorieK (ours)** | local | 606 | **56.3** | **24.1%** |
| Food-R1 (8-bit) | [`zy12123/Food-R1`](https://huggingface.co/zy12123/Food-R1) | 605 | 77.0 | 32.9% |
| Boba 0.8B (Q4_K_M) | [`opengvlab/Boba`](https://huggingface.co/opengvlabs/Boba-LLM-tiny) | 605 | 79.5 | 33.9% |
| DPF-Nutrition | [`T0MYYY/dpf-nutrition`](https://huggingface.co/T0MYYY/dpf-nutrition) | 606 | 109.1 | 46.6% |
| CalorieCLIP | [`jeong-jasonji/CalorieCLIP`](https://huggingface.co/jeong-jasonji/CalorieCLIP) | 606 | 126.4 | 54.0% |
| OmniFood FAFM | OmniFood8K release | 606 | 132.5 | 56.6% |
Food-R1 and Boba each had one image (`dish_1565810969`) where the model entered a non-terminating
token loop; their coverage is 99.83% on 608. All other models cover 100%.
### MM-Food-100K v2 transfer β 499 disjoint images (OOD)
| Model | OOD-eligible? | n | Calorie MAE | PMAE | Bias |
| --- | :---:| ---:| ---:| ---:| ---:|
| DPF-Nutrition | yes | 499 | 177.7 | 76.4% | +30.8 |
| **CalorieK (ours)** | yes | 499 | 181.2 | β | +82.3 |
| Boba 0.8B (Q4_K_M) | yes | 499 | 193.4 | 83.1% | β24.5 |
| OmniFood FAFM | yes | 499 | 216.1 | 92.9% | +131.2 |
| CalorieCLIP | yes | 499 | 225.3 | β | +156.4 |
| Food-R1 | **no** | β | β | β | trained on CalorieBench-80K derived from MM-Food-100K |
DPF-Nutrition wins OOD by 3.5 kcal β inside the noise band, not a decisive lead without a
paired bootstrap. The locked-test gap (56.3 vs 77.0 for the next-best public model) is the
cleaner signal: under an identical, in-distribution protocol, CalorieK's calorie MAE is 27%
lower than the next open-source VLM.
**What "same protocol" means.** Same camera-A center frame per dish as the locked headline
test; same scalar calorie target from the official Nutrition5k dish metadata; same MM-Food-100K
v2 manifest for OOD; same JSON-completeness and memory controls for the GGUF runner
(`scripts/benchmark_gguf_food.py`); and the published PMAE / MAE definitions, not the paper
authors' own splits. Public PyTorch adapters: `src/ccalorie/external_bench.py`. Locked
predictions: `outputs/external_bench_locked/`, `outputs/boba_locked/`. OOD predictions:
`outputs/external_bench_mmfood/`, `outputs/boba_mmfood/`.
## Training method
- **Architecture**: `google/siglip2-base-patch16-224` vision tower (92M params) β LayerNorm β
MLP head (768β768β256β5) with softplus outputs. Trained with composite Huber loss, per-task weighted.
- **Augmentation**: MixUp + CutMix (both Ξ±=0.4, 50% probability per batch), plus standard
RandomResizedCrop, HorizontalFlip, Rotation, ColorJitter.
- **Schedule**: 20 epochs Γ 20,000 samples, effective batch 256 (bf16), head LR 1e-4 / trunk LR 2e-5,
cosine schedule with restarts, 5% warmup, first 5% head-only, last 4 vision blocks unfrozen,
weight decay 0.05, seed 17.
- **Checkpoint selection**: best validation calorie MAE on 80 held-out camera-A dishes (validation MAE 57.3 kcal).
Training ran ~13 minutes on one RTX 4080 (16 GB).
- **Calibration**: a conformal radius fitted on 86 held-out calibration dishes gives 90% prediction
intervals of Β±119 kcal (`calibration.json`).
## Training data (20,000 samples, leakage-audited)
| Source | Samples | Notes |
| --- | --- | --- |
| [`pinkieseb/nutrition_dataset`](https://huggingface.co/datasets/pinkieseb/nutrition_dataset) (Nutrition5k video frames) | 19,734 | only dishes provably in the **official Nutrition5k train split** (matched by exact nutrition signature), β€16 frames per dish |
| [`aryachakraborty/Food_Calorie_Dataset`](https://huggingface.co/datasets/aryachakraborty/Food_Calorie_Dataset) | 266 | human-estimated labels, down-weighted (0.4) |
Nutrition labels are taken directly from the official Nutrition5k dish metadata. Leakage controls,
all enforced programmatically before training:
- **Group separation**: splits partition by physical dish (`physical_meal_id`); no dish appears in two splits.
- **Exact-media**: SHA-256 disjointness across fit / validation / calibration / test.
- **Near-duplicate**: perceptual-hash disjointness; 2 fit images visually colliding with held-out frames were dropped and backfilled.
- **Official-split proof**: a Nutrition5k registry maps nutrition signatures to official train/test IDs;
ambiguous signatures are excluded. The audit trail (SHA-256 of every manifest and the source tree) ships in the results.
## Usage
```python
import torch, torch.nn as nn, torch.nn.functional as F
from transformers import AutoImageProcessor, SiglipVisionModel
from huggingface_hub import hf_hub_download
from PIL import Image
class NutritionHead(nn.Module):
def __init__(self, input_dim: int = 768):
super().__init__()
self.net = nn.Sequential(
nn.LayerNorm(input_dim), nn.Linear(input_dim, 768), nn.GELU(), nn.Dropout(.15),
nn.Linear(768, 256), nn.GELU(), nn.Dropout(.10), nn.Linear(256, 5))
def forward(self, x): return F.softplus(self.net(x))
MODEL_ID = "google/siglip2-base-patch16-224"
processor = AutoImageProcessor.from_pretrained(MODEL_ID)
vision = SiglipVisionModel.from_pretrained(MODEL_ID)
head = NutritionHead()
state = torch.load(hf_hub_download("Dralkh/CalorieK", "caloriek.pt"), map_location="cpu", weights_only=False)
vision.load_state_dict(state["vision_state"]); head.load_state_dict(state["head_state"])
vision.eval(); head.eval()
image = Image.open("meal.jpg").convert("RGB")
pixels = processor(images=image, return_tensors="pt").pixel_values
with torch.inference_mode():
out = vision(pixel_values=pixels)
feats = out.pooler_output if out.pooler_output is not None else out.last_hidden_state[:, 0]
kcal, mass_g, protein_g, carb_g, fat_g = head(feats.float())[0].tolist()
print(f"{kcal:.0f} kcal Β± 119 (90% interval), {mass_g:.0f} g, P{protein_g:.0f}/C{carb_g:.0f}/F{fat_g:.0f}")
```
## Limitations
- **Domain**: trained and evaluated on Nutrition5k-style plated single dishes photographed from the side;
accuracy on multi-plate scenes, packaged food, or drinks is untested.
- **High-calorie under-prediction**: the model plateaus and under-predicts on dishes above ~500 kcal
(see the scatter above). Use the calibrated interval, not the point estimate, for anything that matters.
- **Frames, not meals**: training frames come from videos of ~1,250 distinct dishes; visual diversity is
narrower than the sample count suggests.
## Citation
```bibtex
@misc{caloriek2026,
title={CalorieK: Accurate Food Calorie Estimation from a Single RGB Image},
author={Faisal Alkharji},
year={2026},
url={https://huggingface.co/Dralkh/CalorieK}
}
```
|