CalEyeZ

Automated nutritional analysis by sensor fusion: dual-model food recognition, BLE gravimetric weight, and USDA nutrient lookup.

Electrical & Electronic Engineering B.Sc. Final Project · Shenkar · Raz Dvora & Roi Tzur · Supervisors: Dr. Gabriela Dorfman Furman, Dr. Zeev Weissman

Overview

CalEyeZ removes the friction from nutrition tracking. The user places food on a scale and takes one photo; the system returns calories and a macronutrient breakdown automatically. It solves two independent engineering problems and proves each one with data.

88.18%
Global model top-1 (clean test)
86.2%
full routed system top-1 (V2)
96.1%
arbiter accuracy (ROC-AUC 0.97, V2)
132 + 14
global + Israeli classes (incl. background)
Latest improvement (V2 open-set retrain). Adding a "background" class to the Israeli model made its confidence trustworthy, which let the arbiter route correctly: system top-1 83.8% → 86.2%, Israeli-domain accuracy 60.8% → 77.9%, routing ROC-AUC 0.933 → 0.973. Full before/after in the Israeli Model V2 tab.

The two questions

QuestionEngineering problemSolution (proven)
What is it? Identify the food, including local Israeli dishes that international models never saw, without the catastrophic forgetting that ruined a single combined model. A Global expert (132 classes) and an Israeli expert (13 classes), combined by a learned XGBoost arbiter. See Arbiter.
How much? Obtain an accurate mass from a single photo. Read the mass directly from a reverse-engineered BLE scale, after five vision based volume methods were measured and rejected. See Validation.

Pitch, audience, goals

Run-time architecture

📷 Camera Global model (132)+ Israeli model (13) XGBoost arbiter food class
⚖️ BLE scale weight (g) fusion (class × grams) USDA API kcal + macros

Edge desktop application in Python with a CustomTkinter GUI. Inference runs locally on an NVIDIA GPU; only the nutrient lookup uses the network.

Key project risks (tracked)

RiskWhy it mattersStatus
Catastrophic forgetting when adding local foodOne combined model lost accuracy on both old and new classescontrolled by the two-expert split
Data leakage inflating accuracyDuplicate images across splits make the test score a lieremoved by exact + perceptual de-dup
Class imbalance hurting rare classesIngredient classes have far fewer images than dishesmeasured and reported per class
Arbiter sending the image to the wrong expertA wrong decision wastes a correct prediction4.91% of images, fully decomposed
Weight error from vision volume estimationVolume to mass fails on densityavoided by direct BLE reading

The full risk-to-test mapping is in the Validation & Risks tab.

Then vs Now presented in class current

This tab compares exactly what was shown at the review conference against the rebuilt system. Every "then" number is recomputed from the original results file FINAL_REPORT_v3_results.csv (16,720 images); every "now" number is from the current leakage-free evaluation.

The headline is not the system percentage, it is trust. The old 80.30% sat on a dataset with cross-split leakage and a router that almost never used the second expert. The new 86.2% (V2) sits on a de-duplicated, leakage-free split, with a true arbiter that genuinely recovers Israeli dishes. The new number is both higher and honest.

Headline comparison

MetricThen (class)NowChangeNote
Full system top-180.30%86.2% (V2)+5.9clean data + V2 open-set arbiter
Global model top-1 (own domain)84.6%88.18%+3.58apples to apples: each model on its own classes (158 then, 132 now). 84.6% is the as-presented figure; recomputed on this CSV it is 82.2%
Always-global baseline (full mix)76.43%77.98%+1.55using only the global model on every image, including Israeli food it scores 0% on
Oracle ceiling (best possible arbiter)82.97%88.40%+5.43headroom the models allow
Classes (global)158 to 161132cleanedmerged, renamed, de-duplicated
Israeli expert classes813 (+background)+5broader local coverage; V2 adds an open-set background class
Arbiter accuracy / ROC-AUC78.7%96.1% / 0.973betterthe arbiter (XGBoost); V2 added i_p_background and lifted AUC 0.933 to 0.973
Val vs test gap (leakage signal)about 4 pts0.02 ptclosed88.16 val vs 88.18 test

Why the old router barely used the second expert

In the class version the router chose the old model on 95.0% of images (15,877 of 16,720) and the new model only 5.0% of the time. The new expert was contributing only 6.54% correct answers, so the router had almost nothing to gain by switching. In other words it was not really routing, it was defaulting to the larger model. The current arbiter is a genuine decision maker, explained in full in the Arbiter tab.

Router decision (then)CountShare
Chose OLD model15,87795.0%
Chose NEW model8435.0%

The current arbiter is trained as an explicit domain detector (route_to_israeli = (domain == israeli)), reaches Israeli recall of 79% with global recall 98% in V2 (up from 61% / 98% after the open-set background retrain), and the Arbiter tab explains exactly which features drive each decision. It is also why the current component is correctly called an arbiter, not a router: it adjudicates between two experts that have already run, rather than forwarding by a rule.

What changed structurally

AreaThenNow
BackboneYOLOv8-cls, imgsz 224YOLO11l-cls, imgsz 320
Calibrationasymmetric temperature scaling (T=0.55 / 1.0) to hide probability dilutionnot needed; arbiter learns relative confidence directly
Data hygieneno de-dup, leakage between splitsSHA-1 exact + 64-bit dHash perceptual de-dup, verified zero cross-split leaks
Reported metricraw router accuracyROC-AUC + per-domain recall + full error decomposition

Source files: FINAL_REPORT_v3_results.csv (then), datasets/system_evaluation.csv and arbiter_dataset.csv (now).

Dataset

The global dataset was consolidated from the inconsistent 158-class set into a clean, leakage-free 132-class label space, then re-split 70/20/10. This is where two of the reviewer's points are answered directly: class balance and leakage. Pipeline: scripts/flatten_general_dataset.py.

158→132
classes
99,307
images
981
exact dups dropped
0
cross-split leaks

Where the classes went (158 to 132, fully accounted)

The reduction is not deletion of useful classes. Most removed labels either moved to the Israeli expert or were folded into a class they were visually identical to. Here is every change.

OperationCountDetailReason
Moved to Israeli expert8baklava · bourekas_cheese · falafel · hummus · samosa · schnitzel · shakshuka · shawarma. Pita and plural variants (falafel_pita, shawarma_pita, samosas, bourekas) were merged into the base dish; two very rare dishes (hraime, knafeh) were dropped for too few images.Local dishes left the global model and became the Israeli expert's job. They are not lost, they are handled by the other model and recovered by the arbiter
Merge8filet_mignon + prime_rib → steak · beef_tartare + tuna_tartare → tartare · frozen_yogurt → ice_cream · dumplings → gyoza · sweetcorn → corn · capsicum → bell_pepperClasses humans cannot reliably separate in a photo only add confusion and split training data
Delete8baby_back_ribs · pork_chop · pulled_pork_sandwich (pork, out of scope) · paprika (mixed/noisy) · soy_beans (redundant with edamame) · beignets · huevos_rancheros · sweetpotatoOut of scope or unusable; noisy labels lower every other class's accuracy
Rename5potato → baked_potato · chilli_pepper → chili_pepper · jalepeno → jalapeno · raddish → radish · edame → edamameCasing and spelling collisions created duplicate label folders (still present, just renamed)
Reconciliation: the old labels, minus the local dishes that moved to the Israeli expert, minus the classes merged away and deleted, give the clean 132-class global label space. The renames stay in place. So the "missing" classes are overwhelmingly the local dishes that now live in the Israeli model, which is the whole point of the two-expert design.

What each model is trained to recognize

The two experts cover disjoint label spaces. The global model knows 132 international foods; the Israeli model knows 13 local dishes. No class appears in both, which is why routing reduces cleanly to a domain decision.

Global model

132 international classes
  • almonds
  • apple
  • apple pie
  • avocado
  • baked potato
  • banana
  • beef carpaccio
  • beet salad
  • beetroot
  • bell pepper
  • bibimbap
  • brazil nuts
  • bread pudding
  • breakfast burrito
  • bruschetta
  • cabbage
  • caesar salad
  • canned tuna
  • cannoli
  • caprese salad
  • carrot
  • carrot cake
  • cashews
  • cauliflower
  • ceviche
  • cheese plate
  • cheesecake
  • chicken curry
  • chicken quesadilla
  • chicken wings
  • chili pepper
  • chocolate cake
  • chocolate mousse
  • churros
  • clam chowder
  • club sandwich
  • coconut
  • corn
  • couscous
  • crab cakes
  • creme brulee
  • croque madame
  • cucumber
  • cup cakes
  • deviled eggs
  • donuts
  • edamame
  • eggplant
  • eggs benedict
  • escargots
  • fish and chips
  • foie gras
  • french fries
  • french onion soup
  • french toast
  • fried calamari
  • fried rice
  • garlic
  • garlic bread
  • ginger
  • gnocchi
  • grapes
  • greek salad
  • grilled cheese sandwich
  • grilled salmon
  • guacamole
  • gyoza
  • hamburger
  • hazelnuts
  • hot and sour soup
  • hot dog
  • ice cream
  • jalapeno
  • kiwi
  • lasagna
  • lemon
  • lettuce
  • lobster bisque
  • lobster roll sandwich
  • macadamia
  • macaroni and cheese
  • macarons
  • mango
  • miso soup
  • mussels
  • nachos
  • omelette
  • onion
  • onion rings
  • orange
  • oysters
  • pad thai
  • paella
  • pancakes
  • panna cotta
  • pear
  • peas
  • pecans
  • peking duck
  • pho
  • pine nuts
  • pineapple
  • pistachios
  • pizza
  • pomegranate
  • poutine
  • radish
  • ramen
  • ravioli
  • red velvet cake
  • risotto
  • sashimi
  • scallops
  • seaweed salad
  • shrimp and grits
  • spaghetti bolognese
  • spaghetti carbonara
  • spinach
  • spring rolls
  • steak
  • strawberry shortcake
  • sushi
  • tacos
  • takoyaki
  • tartare
  • tiramisu
  • tomato
  • turnip
  • waffles
  • walnuts
  • watermelon
  • white rice

Israeli model

13 local classes
  • baklava
  • bourekas cheese
  • falafel
  • hummus
  • jachnun
  • malawach
  • meorav yerushalmi
  • sabich
  • samosa
  • schnitzel
  • shakshuka
  • shawarma
  • sufganiyah

De-duplication: how leakage was removed

Two stages run before splitting, so the split unit is a content cluster and never a raw file:

Verified guarantee: no image or near-twin appears in two splits. This is what made the val and test scores agree (88.16 vs 88.18). Before this step the gap was about 4 points, which was leakage inflating the test score.
SplitImagesShareClasses
train69,49170.0%132
val19,82720.0%132
test9,96010.0%132
total99,307100%132

Class balance (the reviewer's point)

The classes are not perfectly balanced, and that is reported honestly rather than hidden. Dish classes hold roughly 750 to 1,000 images each; ingredient classes (single fruits, nuts) hold far fewer. The effect is confined to the smallest classes and is a small-sample effect, not a systematic weakness. The training uses class-aware sampling and heavy augmentation to compensate, and per-class recall is reported in the System Analysis tab so the imbalance is visible, not papered over.

Global Model 88.18% clean test

YOLO11l-cls (Ultralytics, ImageNet-1k pretrained, about 13M parameters), fine-tuned on the 132-class set. Run directory: runs/general_model_flattened/.

Why this model, backed by data

DecisionAlternativesWhy this one
YOLO11l-cls backboneYOLOv8-cls (previous), ResNet-50, EfficientNetNewer C3k2 backbone gave a higher clean top-1 at the same VRAM budget; classification head is simple to fine-tune and export
imgsz 320 (was 224)224, 416, 640320 lifted top-1 over 224 while still fitting batch 16 in about 2.2 GB; 640 gave no measurable gain for far more compute
Heavy augmentationlight / default augDeliberate trade: a moderate-aug retrain scores about 90% on this clean test but is more brittle in the classroom. Robustness was chosen on purpose
Two experts, not onesingle 145-class modelThe single combined model lost accuracy on both old and new classes (catastrophic forgetting). Two experts plus an arbiter avoid it

Training configuration and why we chose each setting

SettingValueWhy this choice
imgsz320Food is a fine-texture problem (rice grains, crumbs, sauce sheen). 320 beat the default 224 on top-1 while still fitting batch 16 on an 8 GB GPU; 640 gave no measurable gain for far more compute.
batch16The largest batch that fits comfortably at 320 px with AMP headroom, large enough for stable batch-norm and gradient estimates without gradient accumulation.
optimizerAdamW + cosine LRAdamW decouples weight decay from the adaptive step, which fine-tunes a pretrained backbone reliably; cosine annealing lowers the LR smoothly to a good minimum with no hand-tuned step schedule.
lr0 / lrf1e-3 / 0.011e-3 is the standard AdamW base for fine-tuning an ImageNet-pretrained backbone; lrf 0.01 decays to 1% of that by the end so late epochs refine the weights instead of thrashing them.
epochs / patience150, patience 30 (stopped 116)A generous cap so the model is never under-trained, with early stopping so it does not waste compute; patience 30 tolerates the noisy validation plateau before stopping.
dropout0.2Regularises against overfitting the large dish classes (about 1,000 images each) given the heavy class imbalance.
label smoothing0.1Stops the model becoming over-certain on visually ambiguous pairs (mousse vs cake), which improves probability calibration. Better-calibrated confidences directly feed the arbiter's features.
AMP (mixed precision)on (~2.2 GB)Halves memory and speeds training with no accuracy loss, leaving VRAM headroom on the 8 GB card for the 320 px / batch 16 setup.
best epoch73best.pt is frozen at the validation peak, so the mild overfit that begins after about epoch 60 (train loss down, val loss up) never affects the reported number.

Robustness to lighting and blur: the trade-off we chose on purpose

A model that scores well in a studio but fails on a darker plate is useless as a product. We therefore treated harsh, variable lighting as a first-class requirement and trained for it directly with aggressive photometric augmentation. On every training batch each image is re-lit at random, so the network never sees the "one true lighting" of a dish and is forced to learn features that are invariant to it.

AugmentationValueWhat real-world condition it defeats
hsv_v (brightness)0.50Exposure / dim vs bright light. Each image is shown with brightness ×U(0.5, 1.5) - from 50% darker to 50% brighter. This is the "darker plate" case, seen thousands of times.
hsv_h (hue)0.02Colour temperature - warm tungsten vs cool fluorescent/daylight white balance.
hsv_s (saturation)0.75Washed-out vs vivid colour under different lights (×U(0.25, 1.75)).
custom blurp=0.35Motion/defocus/Gaussian blur (Albumentations) on 35% of batches: a live webcam is rarely perfectly sharp. 65% stay sharp so clean food is still learned.
erasing0.40Random occlusion - glare spots, a hand, or clutter over part of the dish.
degrees / scale / translate / flips20 / 0.60 / 0.15 / 0.5Pose invariance: food has no canonical orientation and the demo camera is hand-held and angled.

How it works. Per image, Ultralytics samples a random gain and rescales the colour channels: V ← V·U(0.5,1.5), S ← S·U(0.25,1.75), H ← (H·U(0.98,1.02)) mod 180. Over the ~116 training epochs each photo is seen ~116 times, each time under a different random lighting, white balance and exposure.

The deliberate trade-off (robustness over peak accuracy). Aggressive augmentation is a regulariser: it spends model capacity on invariance instead of memorising the exact look of the training set. A moderate-augmentation retrain scores about 90% on this clean test split, but is measurably more brittle in the classroom; our heavy-augmentation model scores 88.18% and holds up under real conditions. We chose the ~2-point clean-test cost on purpose, because the deployment environment is a kitchen, not a lab.
The proof it worked, in our own numbers. The near-zero validation-vs-test gap (88.16% vs 88.18%) shows the model is not overfitting - exactly what heavy augmentation buys; an under-augmented model would show a large gap and collapse off-distribution. And it transfers: on unseen real phone photos the system scored 30/40 = 75% (about 85% on the expanded set), and a pepper reads 99.97% across wood and dark backgrounds.

Training code

# scripts/training/train_general_model.py  (essentials)
from ultralytics import YOLO

model = YOLO("yolo11l-cls.pt")            # ImageNet-1k pretrained
model.train(
    data="datasets/general_model_flattened",  # 132 classes, 70/20/10
    imgsz=320, batch=16, epochs=150, patience=30,
    optimizer="AdamW", lr0=1e-3, lrf=0.01, cos_lr=True,
    dropout=0.2, label_smoothing=0.1,
    augment=True, hsv_h=0.02, hsv_s=0.75, hsv_v=0.50,  # lighting/temp/exposure
    degrees=20, translate=0.15, scale=0.60, flipud=0.15, fliplr=0.50, erasing=0.40,
    # + custom Albumentations blur (motion/defocus/gaussian) on 35% of batches via a train-batch callback
    project="runs", name="general_model_flattened",
)
# validate on the held-out TEST split
model.val(data="datasets/general_model_flattened", split="test")

Results (best.pt, epoch 73)

88.18%
test top-1
96.94%
test top-5
88.16%
val top-1
≥80%
FRS requirement
Trustworthy number. val (88.16%) is within 0.02 of test (88.18%), so the splits agree and the de-duplication did its job. The previous model showed a roughly 4-point val/test gap that was leakage. After about epoch 60 the train loss keeps falling while val loss drifts up (mild overfit), but best.pt is frozen at the val peak (epoch 73), so the reported number is unaffected.

Most-confused class pairs (test)

Every meaningful confusion is a plausible visual overlap, not a failure:

True → PredictedRate
chocolate_mousse → chocolate_cake12%
apple_pie → bread_pudding10%
gnocchi ↔ ravioli7%
beef_carpaccio → tartare6%
lobster_bisque ↔ clam_chowder5%
Global model normalized confusion matrix
Normalized confusion matrix, 132 classes. A clean main diagonal with off-diagonal mass only between genuinely similar dishes.

Israeli Model 91.7% on its domain

YOLO11l-cls, 13 Israeli classes (baklava, bourekas_cheese, falafel, hummus, jachnun, malawach, meorav_yerushalmi, sabich, samosa, schnitzel, shakshuka, shawarma, sufganiyah). Run: runs/israeli_food_yolo11l/, imgsz 224.

Why a second, separate expert

The first attempt added Israeli food to the global model and retrained. The combined model got confused: it lost accuracy on both the international classes it already knew and the new local ones. This is catastrophic forgetting, and it was the trigger for the whole arbiter architecture. Keeping the Israeli dishes in their own small expert means the global model never degrades, and the local model can specialise hard on a narrow domain where it reaches about 92% while the global model scores 0% on those same classes.

Training configuration and why we chose each setting

SettingValueWhy this choice
backboneYOLO11l-clsThe same backbone as the global model so both experts produce comparable confidence and embedding features, which keeps the arbiter's inputs consistent across the two models.
classes13A narrow, curated local-cuisine set. Keeping the domain small lets this expert specialise hard and reach about 92% where the global model scores 0% on the same dishes.
imgsz224Lower than the global model's 320 because the dataset is much smaller; 224 trains fast, avoids overfitting the limited data, and the local dishes are distinguishable at this resolution.
epochs63 (best 59)The small dataset converges quickly, so a short schedule is enough; best.pt is frozen at epoch 59 (the validation peak) before the noisy tail.
datadatasets/israeli-food-masterA dedicated, hand-collected local-food set, kept separate from the global data so adding local cuisine never degrades the global model (no catastrophic forgetting).
best top-191.70%Strong on its own domain (top-5 about 99%), which is what makes the two-expert split worthwhile. Its out-of-domain overconfidence is handled by the arbiter, not by this model.

Results

91.70%
best top-1 (own domain)
99.1%
top-5 (own domain)
13
classes

The accuracy curve is noisier than the global model's because the dataset is much smaller, so each epoch sees fewer images and the validation estimate has higher variance. The trend is still a clean climb to about 92%.

Known behaviour the arbiter must handle

Overconfident out-of-domain. Because it only ever saw 13 dishes, this model will still output a high probability (it can exceed 0.9) for an image of food it has never seen. A naive confidence threshold therefore cannot decide which expert to trust. This is precisely why the routing problem is hard and why a learned arbiter, not a fixed rule, is required.
Israeli model normalized confusion matrix
Normalized confusion matrix, 13 Israeli classes.

Arbiter acc 96.1%, ROC-AUC 0.97 (V2)

How the arbiter works, in full. This tab defines exactly what the arbiter is, what it reads, what it decides, and what it cannot do, with the math for every feature, so there is no ambiguity about its role in the system.

Why we call it an arbiter, not a router

A router would forward the image to one model based on a fixed rule before seeing any result. Our component does the opposite: it runs both experts first, then weighs their competing evidence (confidence, entropy, margin) and adjudicates which one to trust. That is arbitration, not routing. The distinction matters because the decision is made on the models' actual behaviour for this specific image, not on a prior guess about where the image should go.

What the arbiter is, in one paragraph

The arbiter is not a third food classifier. It never looks at the image. It looks only at the two experts' output behaviour for that image (their confidences, how spread out their guesses are, how decisive each one is) and answers a single yes/no question: does this image belong to the Israeli expert's domain? If yes, the system returns the Israeli model's label; if no, it returns the global model's label. Formally the target is route_to_israeli = (domain == israeli). Because the two label spaces do not overlap, routing reduces cleanly to domain detection.

Global outputs Israeli outputs feature vector (no pixels) XGBoost: P(israeli) pick the expert

The 20 features and their math

The arbiter input is a 20-number vector: 7 numbers summarising the Global model's output, 8 for the Israeli model (V2 adds i_p_background), and 5 interaction terms that compare the two. Below, p_k is the k-th highest softmax probability and K the number of classes. The "g_" prefix is the Global model, "i_" the Israeli model.

#FeatureMeaningFormula
1-5g_conf1 … g_conf5Global model's top-5 class probabilitiestop-5 of softmax(global)
6g_entropyHow spread out the Global guess is (low = decisive)−Σ p_i · ln(p_i)
7g_marginHow decisive the Global top guess isg_conf1 − g_conf2
8-12i_conf1 … i_conf5Israeli model's top-5 class probabilitiestop-5 of softmax(israeli)
13i_entropySpread of the Israeli guess−Σ p_i · ln(p_i)
14i_marginDecisiveness of the Israeli top guessi_conf1 − i_conf2
15i_p_background (V2)Israeli model's "not one of my dishes" probability. High = route to global. Now the #1 feature by gain (0.289).softmax(israeli)[background]
16conf_gapWhich expert is more confident at the topg_conf1 − i_conf1
17conf_ratioRelative top-1 confidence (scale-free)g_conf1 / (i_conf1 + 1e-6)
18entropy_gapWho is more uncertain (>0 = Israeli more uncertain)i_entropy − g_entropy
19margin_gapWhose decision is sharperg_margin − i_margin
20both_unsureFlag: neither expert is confident1 if (g_conf1<0.5 and i_conf1<0.5) else 0
In V2 the top feature is i_p_background (gain 0.289), then the interaction terms. They encode relative behaviour, which is what actually separates the domains. i_p_background, entropy_gap and conf_gap rank highest by importance: the arbiter trusts the Israeli model when it is confident, the Global model is comparatively uncertain, and the Israeli model is not signalling "background". That open-set signal is exactly why V2 survives the Israeli model's out-of-domain overconfidence and lifted routing AUC from 0.933 to 0.973.

Seeing the math live (per image)

The demo does not just print a probability. For each capture it computes the exact TreeSHAP contribution of every feature, that is, how many log-odds each one added toward "route to Israeli", and shows the top contributors under the decision panel. A typical readout for a global food:

WHY (top feature contributions):
conf_gap     =  0.640  +1.31 -> Global    # global far more confident than israeli
entropy_gap  = -0.220  +0.74 -> Global    # israeli more uncertain than global
i_conf1      =  0.180  +0.42 -> Global    # israeli top-1 is weak

This is the faithful per-prediction explanation (not a global average), so during the demo you can point at the exact numbers that produced the routing decision.

The arbiter decides on relative confidence between the experts, not on the Israeli model's raw confidence, which is exactly why it survives that model's out-of-domain overconfidence.

The softmax behind the features (with code proof)

Every confidence the arbiter consumes is a softmax probability. Softmax turns a model's raw logits z into a probability distribution over classes:

softmax(z)_i = e^(z_i) / Σ_j e^(z_j)        # non-negative, sums to 1

Where it runs: we do not hand-write softmax. Each YOLO11l-cls expert applies it inside its classification head (Ultralytics / PyTorch), so results.probs is already a softmax distribution. Our pipeline consumes that output and derives the features from it. Proof, straight from scripts/arbiter/generate_arbiter_dataset.py (identical in the live demo's expert_features):

probs     = r.probs                          # the Classify head's softmax output
top5_conf = [float(c) for c in probs.top5conf.tolist()]   # top-5 softmax probabilities
full      = probs.data.cpu().numpy()         # the full softmax vector (sums to 1)
full      = np.clip(full, 1e-12, 1.0)        # guard log(0)
entropy   = float(-(full * np.log(full)).sum())           # H = -Σ p ln p
margin    = float(top5_conf[0] - top5_conf[1])        # p1 - p2

So g_conf1..5 / i_conf1..5 are softmax probabilities, *_entropy is the entropy of the softmax vector, *_margin is the gap between its top two, and i_p_background is the softmax mass on the background class. All 20 features are functions of these two softmax distributions, never the raw logits.

One subtlety: the arbiter's own output is a sigmoid, not softmax. The arbiter is a 2-class decision (global vs Israeli), so its probability is the logistic sigmoid, which is just the two-class special case of softmax: P(israeli) = 1 / (1 + e^(-logit)). The live demo recovers that logit with logit = ln(p / (1 - p)) to show the math panel. In short: the experts use softmax (proof above); the arbiter uses its sigmoid sibling.

Why XGBoost, backed by data

A Random Forest looked great on training data but collapsed on held-out data: it memorised. XGBoost gave essentially the same test accuracy with a tiny generalisation gap, so it was chosen.

MetricRandom ForestXGBoost (chosen)Conclusion
Train accuracy98.1%81.2%RF overfit badly; XGBoost is stable, so it was selected
Test accuracy78.6%78.7%
Overfit gap19.5%2.5%

Those figures are from the earlier router study; the current V2 arbiter keeps XGBoost and reaches routing accuracy 96.07% and ROC-AUC 0.973 on the rebuilt dataset (with the i_p_background feature).

Training code

# scripts/arbiter/train_arbiter_xgb.py  (essentials)
import xgboost as xgb, pandas as pd

df = pd.read_csv("datasets/arbiter_dataset.csv")   # both models over val+test
y  = (df.domain == "israeli").astype(int)        # route_to_israeli target

# features: each model's conf/entropy/margin + interaction deltas
X = df[["g_conf1","g_entropy","g_margin",
        "i_conf1","i_entropy","i_margin",
        "conf_gap","conf_ratio","entropy_gap","margin_gap"]]

train = df.split == "val"                          # train on val rows
test  = df.split == "test"                         # evaluate on test rows

clf = xgb.XGBClassifier(
    n_estimators=400, max_depth=4, learning_rate=0.05,
    subsample=0.8, colsample_bytree=0.8,
    scale_pos_weight=0.5,                         # Israeli minority ~7%
    eval_metric="auc", early_stopping_rounds=30)
clf.fit(X[train], y[train], eval_set=[(X[test], y[test])])
clf.save_model("scripts/arbiter/arbiter_xgb.json")

Results (V2 arbiter)

What is ROC-AUC, in plain words? The arbiter outputs a score P(israeli) for every image. We sweep a decision threshold from high to low; at each setting we plot two numbers: how many Israeli images it correctly catches (the True Positive Rate, up) against how many global images it wrongly sends to the Israeli model (the False Positive Rate, right). Joining those points draws the ROC curve. The area under that curve (AUC) is a single 0-1 score: 1.0 = perfect separation, 0.5 = a coin flip (the dashed diagonal). Equivalently, AUC is the probability the arbiter ranks a random Israeli image above a random global one. A curve that hugs the top-left corner is better - it catches Israeli images without misrouting global ones.
random · AUC 0.5 perfect 0.00.51.0 0.00.51.0 False Positive Rate - global images mis-routed to Israeli → True Positive Rate - Israeli caught → AUC = 0.973 V2 arbiter · V1 was 0.933

How to read it. This is the real V2 routing ROC, computed on the 11,352-image held-out test set (route_prob_israeli vs. true domain in datasets/system_evaluation.csv). The teal curve shoots almost straight up the left edge then runs along the top - it catches most Israeli images while misrouting very few global ones. The area beneath it is 0.973.

Why it matters here. Moving the threshold just slides you along one curve (more Israeli caught costs more global misrouted - zero-sum). The only way to win net accuracy is to lift the whole curve, i.e. raise the AUC. V2's open-set i_p_background signal did exactly that: 0.933 → 0.973.

Routing ROC curve (V2 arbiter), real test-set data. Closer to the top-left = better separation between the two food domains.

ROC-AUC in one line: the probability that the arbiter gives a randomly chosen Israeli image a higher P(israeli) than a randomly chosen global image. 1.0 is perfect separation, 0.5 is a coin flip. 0.973 means it ranks the two domains correctly 97% of the time. Recall = the share of each domain it actually catches.

Routing metric (test)V1V2
Routing accuracy93.40%96.07%
ROC-AUC0.9330.973
Israeli recall61.3%79.2%
Global recall97.6%98.4%
System top-1 (test)V1V2
Always-global baseline78.0%77.4%
Arbiter (routed)83.8%86.2%
Oracle ceiling88.7%88.8%
Arbiter gain+5.8+8.8 pts
What changed between the two trainings, and why the arbiter got much better. The arbiter algorithm did not change (same XGBoost, same training recipe). What changed is the information it receives, and that is the whole point:
  • The problem in V1: the Israeli model was overconfident on non-Israeli food (it would call a bowl of risotto "hummus" at 80%+). So its confidence was a noisy, untrustworthy signal, the arbiter learned to discount it and default to the global prior, and Israeli recall stalled at 61%.
  • The fix (open-set retrain): we retrained the Israeli model with a 14th background class of non-Israeli food, so it now answers "this is not one of my dishes" instead of forcing a guess. Measured: its mean P(background) is 0.50 on global food vs 0.03 on real Israeli food, a near-clean separator.
  • Exposed to the arbiter as one feature: i_p_background. It instantly became the arbiter's #1 feature by gain (0.289), ahead of every confidence/entropy term.
  • Why this is a real win, not a knob turn: earlier analysis showed that simply moving the decision threshold is near zero-sum (every Israeli image won costs a global image). The only way to gain net accuracy is to raise the ROC-AUC, which needs new information. The background signal did exactly that: AUC 0.933 to 0.973, Israeli recall 61% to 79%, system 83.8% to 86.2%, with global recall holding. The earlier note that "better model calibration would raise Israeli recall, more arbiter tuning would not" is precisely what V2 confirmed.
Model file: scripts/arbiter/arbiter_xgb.json (V2, 20 features). See the Israeli Model V2 tab for the full retrain and the per-class numbers.

CNN Visualizer: what the layers do to one image

This is a live, step-by-step view of how a convolutional classifier turns pixels into a class. Pick an input, then press Play. The same logic runs inside the YOLO11 backbone, just with many more filters and layers.

The exact layers in our model - YOLO11l-cls, start to end

This is not a generic diagram: the table is read directly from our trained best.pt by pushing one 320×320×3 image through the network and recording the output tensor of every block. Our Global model is 11 top-level blocks (which expand to 309 individual layers such as Conv/BatchNorm/SiLU/Linear) and 13.0 million parameters. Resolution shrinks 320→10 while channel depth grows 3→512: the network trades spatial detail for semantic depth, then the head turns the final 512-vector into 132 class probabilities.

#BlockOutput (C×H×W)ParamsWhat it does
0Conv (stem)64 × 160 × 1601,856First convolution, stride 2 halves the image; learns 64 low-level filters (edges, colour blobs) - exactly the kernels in the panels below.
1Conv128 × 80 × 8073,984Downsample again and widen to 128 channels (more filter types).
2C3k2256 × 80 × 80173,824CSP block: splits the features, runs a stack of small bottleneck convs, concatenates. Builds textures from edges.
3Conv256 × 40 × 40590,336Downsample to 40×40.
4C3k2512 × 40 × 40691,712Deeper features → small object parts; widen to 512 channels.
5Conv512 × 20 × 202,360,320Downsample to 20×20.
6C3k2512 × 20 × 202,234,368Mid-level semantic features (combinations of parts).
7Conv512 × 10 × 102,360,320Final downsample to a 10×10 grid.
8C3k2512 × 10 × 102,234,368High-level features (whole-object concepts).
9C2PSA512 × 10 × 101,455,616YOLO11's attention block (position-sensitive self-attention): lets distant regions of the plate inform each other before the decision.
10Classify (head)132827,012Global-average-pool the 512 maps into one 512-vector → dropout → Linear(512→132) → softmax = class probabilities. This is the fingerprint + weights stages below.

The Israeli expert is the identical YOLO11l-cls backbone but fed 224×224 (so the grid is 112→7 instead of 160→10) with a 14-class head - 11 blocks, 12.85M parameters. The four stages below zoom into what happens inside these blocks, and the last stage runs the block-0 style filters on a real photo you upload.

Input: Kernel:

Layer 1 · Convolution (feature extraction)

A small grid of weights (the kernel) slides across the image. At each position it multiplies the overlapping pixels by the weights and sums them. That single number becomes one pixel of the output feature map. Different kernels detect different things: edges, colours, textures.

Input pixels (grayscale)
Kernel weights (3×3)
Output feature map
Press Play to slide the kernel and watch the dot product.

Layers 2 to N · Stacking and pooling (from edges to concepts)

Early layers find edges and colour blobs. Stacking convolutions and downsampling (pooling) lets later layers combine those into textures, then parts, then whole concepts like "rice grains" or "round red object". Each box below is a feature map at increasing depth and decreasing resolution.

edges textures parts objects

Bottleneck · The latent vector (the image's signature)

After the last conv block the whole image is compressed into one long vector of numbers (1280 in this backbone). Lit cells are features that are strongly active for this image. This vector is the image's fingerprint; two photos of the same dish produce similar vectors.

Classifier · Weights turn the signature into a decision

The final layer holds one weight template per class. It takes the dot product of the latent vector with each class template; the class whose template matches best wins. Softmax then turns the scores into probabilities. These templates are literally "the weights".

score(class) = Σ (latent_i × weight_i)

On a real photo · run the filters and see the outcome after each one

Everything above used a tiny synthetic image so the arithmetic was visible. Here it runs for real: upload any food photo and the browser applies real 3×3 convolution kernels to it - exactly the operation the first layer of the YOLO11 backbone performs. Each panel below is the feature map that one filter produces, i.e. the outcome after that filter. Nothing is uploaded to a server; the convolution runs entirely in your browser.

pick a photo to convolve (JPEG/PNG). It is processed locally only.
Input (center-cropped, grayscale)

Layer 1 · six kernels, six feature maps

Layer 2 · pool, then filter again (edges → corners & parts)

The layer-1 edge magnitude is downsampled 2×2 (max-pool) and convolved again. Stacking this is how deeper layers build from edges to textures to parts to whole objects, at lower and lower resolution - the same pattern as the synthetic depth boxes above.

Our real model · true activations from all 11 blocks

The panels above use illustrative textbook kernels. This runs your uploaded photo through our actual trained network (global_features_fp16.onnx, ~26 MB, downloaded once) in the browser via ONNX Runtime Web, and shows the mean activation of every one of the 11 blocks - the real feature maps, start to end, at their true resolutions (160² down to 10²). Bright = strongly firing. The final block is the 132-class softmax, so it also prints what the model actually predicts.

upload a photo first, then run the real model

System Error Analysis

Every error of the full routed system was decomposed on the held-out test set (11,265 images). This separates errors a better arbiter could fix from errors no arbiter can fix. Reproduce with scripts/arbiter/system_analysis.py.

Error decomposition

System outcome decomposition
System correct vs routing error vs both-models-wrong.
OutcomeImagesShareMeaning
System correct9,43583.75%routed to the right model and it was right
Routing error5534.91%a correct model existed, the arbiter chose the other
Both models wrong1,27711.34%unfixable by any arbiter, needs better models
Key finding: of the 16.25% system errors, only 4.91% are routing errors that a better arbiter could recover. The other 11.34% are images where both models were wrong, which no arbiter can fix. So about 70% of all system errors are bounded by model quality, not by the arbiter. The arbiter is already near its useful limit; further gains require better base models.

Accuracy by domain

How to read this chart. Each bar is the system's top-1 accuracy on a slice of the test set. "Global domain" = international foods, "Israeli domain" = the 13 local dishes, "Overall system" = everything combined. The reference levels are the always-global baseline (78.0%, what you get without an arbiter) and the oracle ceiling (88.7%, a perfect arbiter). The overall system at 83.8%, between those, is the real arbiter's contribution. Israeli is lower (60.8%) because those dishes are the hardest to route.

These are the original V1 system numbers (the baseline before the Israeli Model V2 open-set retrain). For the current production system (86.2% overall, 77.9% on the Israeli domain) see the System Analysis V2 tab.

The numbers behind the bars (test split, 11,265 images)

SliceImagesSystem correctSystem accAlways-globalOracle
Global domain9,9608,64186.8%88.2%88.2%
Israeli domain1,30579460.8%0.0%92.3%
Overall system11,2659,43583.8%78.0%88.7%

How the math works. Each bar is simply correct images divided by total images for that slice:

Global   : 8,641 / 9,960  = 86.8%
Israeli  :   794 / 1,305  = 60.8%
Overall  : 9,435 / 11,265 = 83.8%   (= (8,641 + 794) / (9,960 + 1,305))

Always-global baseline : 8,784 / 11,265 = 78.0%   (global model alone on every image;
                                                    it scores 0 of 1,305 Israeli images)
Oracle ceiling         : 9,988 / 11,265 = 88.7%   (= 8,784 global + 1,204 Israeli that
                                                    at least one expert got right)
Why the mix is mostly global. The test split holds 9,960 global images and 1,305 Israeli images (about 88% / 12%). Because Israeli is the smaller and harder slice, its 60.8% pulls the overall number down only modestly, from the global 86.8% to 83.8%. The always-global baseline is exactly the global model used alone: it gets 8,784 of the 9,960 global images but 0 of the 1,305 Israeli images, which is why adding the Israeli expert plus the arbiter lifts the system by 5.78 points (78.0% to 83.8%). Source: datasets/system_evaluation.csv.

For each domain, three bars: the always-global baseline (the old global model used alone), the realized system (global + Israeli experts + arbiter), and the oracle ceiling (the best either expert could do). The story is visible at a glance: on the Israeli slice the baseline is 0% (the global model knows no Israeli dishes), so the Israeli expert plus the arbiter is what creates the 60.8%; on the global slice the system pays a tiny 1.4-point cost (86.8% vs 88.2%) for occasionally misrouting; overall the system beats the baseline by 5.8 points and sits 4.9 points below the oracle.

Top confusions
Most common system confusions, true → predicted.
What still limits the overall number. The gap from the green system bars up to the gold oracle line is the routing error (a correct expert existed but was not chosen), worth about 4.9 points; the gap from the oracle up to 100% is the both-experts-wrong pool, which no router can fix. The Israeli domain has the largest oracle gap (60.8% to 92.3%), so it is where a better arbiter would help most.

How decisively the arbiter separates the two domains

How to read this chart. For every test image the arbiter outputs one number: P(israeli), its estimated probability that the image belongs to the Israeli expert (0 on the left, 1 on the right). The histogram counts how many images fall at each value, on a log scale. Blue bars are images that are truly global, purple are truly Israeli. A good arbiter pushes blue to the left (low P) and purple to the right (high P). The dashed line at 0.5 is the decision threshold: anything to its right is sent to the Israeli model. The clean separation (blue piled near 0, purple piled near 1) is what a 0.93 ROC-AUC looks like; the small amount of blue and purple that overlap in the middle is exactly the routing the arbiter cannot get perfectly right. (This histogram illustrates the V1 arbiter; V2's i_p_background feature sharpened the separation further, lifting AUC to 0.973 and shrinking that middle overlap.)

Israeli recovery
Each bar is 100% of a dish's test images: green recovered (sent to the right expert), red missed.
Routing separability
Histogram of the arbiter's P(israeli) for true-global (blue) vs true-israeli (purple). Overlap near 0.5 is the routing it cannot perfect; V2 (i_p_background) reduced it, AUC 0.933 to 0.973.
Worst major classes
System accuracy for classes with at least 100 test images (excludes small-sample noise).

Case study: the sufganiyah vs donuts confusion, solved

At the previous review this pair was one of the worst failures in the whole system. A sufganiyah (Israeli jam doughnut) and an American donut look almost identical, and the old single-model setup constantly mixed them up. The two-expert split plus a clean visual cue fixed it.

The cue that made it work: in the rebuilt data, donuts in the global model are always a ring of dough with a hole, while sufganiyah in the Israeli model is a filled ball of dough with no hole. That single consistent difference gives each expert a clean, separable signature, so the arbiter can tell which one it is instead of guessing.
ClassThen accuracyNow accuracyConfusion with the other
sufganiyah58.3%88.9%82 images read as donuts then → only 2 now
donuts77.3%91.3%improved, only 5 read as sufganiyah now

Source: old confusion from FINAL_REPORT_v3_results.csv, current from datasets/system_evaluation.csv (test split). This is a concrete lesson from the prior tests carried into the final model, exactly the kind of improvement the review asked us to surface.

Where the arbiter still misses

It handles most Israeli dishes well (sufganiyah 89%, jachnun 83%, shakshuka 76%, baklava 72%, hummus 69%) but struggles where a dish strongly resembles a global food: sabich 14%, shawarma 26%, falafel 43%. The dominant single confusion is falafel → crab_cakes (60 images). The limiting factor is visual feature overlap, not the arbiter logic.

Validation Plan and Risk Coverage

Goals and risks mapped to tests. Every goal and risk is traced to a specific test that proved it was handled. Each row pairs a risk with the concrete test that neutralises it and the evidence produced.

Risk → test → evidence

RiskTest performedEvidence / result
Test accuracy is inflated by data leakageRecompute val and test top-1 after SHA-1 + perceptual de-dup; compare the twoval 88.16 vs test 88.18 (gap 0.02). Leakage removed
Adding local food breaks the global model (catastrophic forgetting)Compare combined-model accuracy against the two-expert splitTwo-expert split keeps global at 88.18% with no degradation
The arbiter defaults to one model instead of truly decidingCount arbiter decisions and measure per-domain recall and ROC-AUCV2: Israeli recall 79%, global recall 98%, ROC-AUC 0.973; +8.8 pts over always-global (was 61% / 0.93 in V1)
A wrong food identification goes unnoticedPer-image error decomposition into routing error vs both-wrong; per-class recall4.91% routing / 11.34% both-wrong, every class measured (System Analysis tab)
Class imbalance silently hurts rare classesPer-class recall reported, smallest classes flaggedEffect confined to ingredient classes with n≈8; reported, not hidden
Weight from vision volume is unreliableVolume estimates validated against Archimedes water displacementVolume to mass fails on density even when volume is right; pivot to BLE
Scale reading is fragile (OCR glare)Compare OCR of the LCD against direct BLE packet decodingBLE is deterministic and glare-free; selected as final

Product vs engineering validation

TypeWhat was validatedHow
Engineering validationEach model and the arbiter meet their numeric targets on held-out, leakage-free dataHeld-out test split, ROC-AUC, error decomposition, confusion matrices
Product validationThe end user gets a correct food name and weight without manual entryEnd-to-end run: photo → class → BLE grams → USDA kcal/macros in the GUI

Weight estimation: approaches measured and rejected

ApproachPrincipleWhy rejected
Coin fiducial + HoughCircles₪1 coin (18 mm) detected, pixels to cm scaleCalibrates the 2-D plane only; no object height
MiDaS depthMonocular relative depth integrated to volumeScale-ambiguous, noisy on small objects, needs a fiducial every shot
Shadow geometryheight = L_shadow · tan(α)Depends on a known light angle; lighting-sensitive
Volume → massmass = volume · densityDensity problem: fluffy and dense foods of equal volume differ greatly
OCR of scale displayRead the 7-segment readoutGlare, angle and lighting fragility on the LCD
BLE (final)Read the weight packet at the sourceSelected: deterministic, robust, no vision error

BLE packet format: weight = low_byte + high_byte × 256, verified by a checksum over bytes 2 to 6. Example: AC 05 00 02 14 00 CE E4 decodes to 532 g. The full reverse-engineering story is in the BLE Scale tab.

Reverse-Engineering the BLE Scale

The weight is read straight off the scale's Bluetooth Low Energy broadcast, so there is no camera, no OCR, and no vision error in the measurement path. The scale is a generic commercial unit with no public protocol, so we recovered the packet format ourselves. This tab documents how.

Step 1 · Find the device

Using the nRF Connect app on a phone, we scanned for nearby BLE advertisers. The scale shows up only under a generic name, so we identified it by signal strength (RSSI): with the phone held against the scale, the correct device is the one whose RSSI jumps toward 0 dBm while every other advertiser stays weak. Stepping away and watching that one value drop confirmed the match.

📱 nRF Connect scan strongest RSSI = our scale connect, list GATT services subscribe to notify characteristic

The device advertises as SWAN and streams weight on the notify characteristic 0000ffb2-0000-1000-8000-00805f9b34fb. Subscribing to it delivers a fresh 8-byte packet every time the reading changes.

Step 2 · Make it talk by changing the input

With the notifications logging, we treated the scale as a black box and changed one variable at a time: place a known mass, record the packet; change the mass, record again. The bytes that moved with weight, and the way they moved, revealed the encoding.

Mass on scaleNotify packet (hex)Byte that changedDecoded
0 gAC 05 00 00 00 00 ..baseline0
100 gAC 05 00 00 64 00 ..byte 4 = 0x64100
200 gAC 05 00 00 C8 00 ..byte 4 = 0xC8200
255 gAC 05 00 00 FF 00 ..byte 4 maxed (0xFF)255
532 gAC 05 00 02 14 00 ..byte 4 rolled over, byte carries the 256s532
The "aha". Byte 4 counts grams 0 to 255, then rolls over. Each full rollover is one unit of 256 g, stored separately. So the weight is a two-byte little-endian number: a low byte (0 to 255) plus a high byte multiplied by 256. Documenting 100 g, 200 g, then crossing 255 g is exactly what exposed the high byte.

Step 3 · The packet layout we recovered

Byte01234567
Roleheaderlenunitstatus + high bitslow bytehigh bytemagicchecksum
ExampleAC0500021400CEE4
low  = packet[4]                          # grams 0..255
high = packet[5] | packet[3]               # number of 256s (carry sits in byte 3)
weight = low + high * 256                  # little-endian, grams

Worked example for AC 05 00 02 14 00 CE E4: low = 0x14 = 20, high = 0x00 | 0x02 = 2, weight = 20 + 2 × 256 = 532 g.

A real bug we fixed (the odd-256 trap). Our first decode masked the high byte with & 0xFE, on the theory that byte 3's low bit was a status flag. It was not: that bit is the lowest bit of the high byte. Masking it silently dropped 256 g from every odd multiple of 256, so 272 g read as 16 g while even weights like 532 g looked fine. The fix is to use the full high byte (no mask). Lesson: never assume a bit is a flag without testing the value range that exercises it.

Step 4 · Robust decode in software (a lesson from a real bug)

A live scale flickers as it settles, and the high byte can momentarily read wrong during a refresh, which would make a 532 g reading drop to 20 g for one frame. Our first attempt held the high byte with a sticky latch. That was a mistake: a single transient overshoot (pressing down so it briefly reads above 255 g) latched a permanent +256 g offset that never cleared, so a load removed and replaced read double (200 g, then 400 g). The latch also refused to return to 0 if the scale stopped sending a clean zero packet.

The fix. Drop the latch and take the median of the last few raw decodes. A single bad frame (flicker or momentary overshoot) is outvoted by the others, so it never shows, and the reading follows the scale all the way back down to 0. No state is held between loads, so weights cannot accumulate. A separate stability gate marks a reading "stable" once the last ten samples vary by no more than 2 g.
from bleak import BleakScanner, BleakClient
from collections import deque
NOTIFY_UUID = "0000ffb2-0000-1000-8000-00805f9b34fb"
raw = deque(maxlen=5)

def on_notify(sender, data):
    b = list(data)
    if len(b) < 8: return
    low  = b[4]
    high = b[5] | b[3]                   # full high byte, no mask (see odd-256 bug)
    raw.append(low + high * 256)
    weight = sorted(raw)[len(raw) // 2]   # median: no latch, returns to 0, rejects spikes
    # ... push to UI, mark STABLE when last 10 samples vary ≤ 2 g

async def run():
    dev = await BleakScanner.find_device_by_name("SWAN")
    async with BleakClient(dev) as c:
        await c.start_notify(NOTIFY_UUID, on_notify)

The weight-calculation state machine (per packet)

Every notification runs through the same fixed pipeline before it becomes a gram value shown to the user. This is the state machine that computes the weight: validate → decode → median → calibrate → stability → emit. A frame that fails validation is dropped, not shown.

BLE notify · 8-byte packet AC 05 00 02 14 00 CE E4 VALIDATE len ≥ 8 · header AC · checksum(2..6) DROP frame DECODE (little-endian) low = byte4 · high = byte5 | byte3 raw = low + high · 256 → 532 g MEDIAN · last 5 raw rejects 1-frame flicker / overshoot CALIBRATE g = 1.178 · median (span-error fix) STABILITY GATE last 10 vary ≤ 2 g ? STABLE MOVING EMIT · current_weight = g pass fail
StageComputationOutput
Validatelen≥8, header ==0xAC, sum(b[2..6])&0xFF==b[7]pass, or drop
Decodelow=b[4]; high=b[5]|b[3]; raw=low+high*256raw grams
Medianmedian of the last 5 raw valuesflicker-free grams
Calibrateg = round(1.178 × median) (through-origin span fix)true grams
Stabilitymax−min of last 10 g ≤ 2 g ?STABLE / MOVING
Emitcurrent_weight = gvalue used for calories
Why this beats the camera-based alternatives. Reading the broadcast is deterministic and lighting-independent. We did implement the OCR fallback (Tesseract on the 7-segment display, in food_app.py), but it was fragile to glare and viewing angle, and every vision volume method failed on the volume-to-mass density problem (see the Validation & Risks tab). BLE removes the measurement from the vision path entirely. Source: gui_scale.py, used live in DEMO.py.

Step 4b · Calibrating the scale: from a 16% span error to a specified instrument

A digital scale is only trustworthy if its reading matches true mass, and calories scale linearly with weight, so any weight error passes straight into the calorie figure. Testing this BLE scale against reference masses exposed a fault: it read a steady ~16% low across the whole range. Crucially the error was systematic, not random: repeated placements of the same mass agreed to within a few grams, which means it is a multiplicative span error, and systematic errors are correctable in software.

The experiment. Ten reference masses from 21 g to 1062 g (water measured on an accurate scale) were each read five times on the scale's own LCD, at a fixed centred placement (a spreader plate removes the single-load-cell eccentricity effect). To test that the fit generalises rather than curve-fitting water, we then measured ten completely different everyday objects (phone, battery, glass, remote, cardboard, camera, toys) as a held-out validation set. Both are plotted below: blue = fit set, amber = held-out objects. A perfect scale would lie on the dashed y = x line; every point sits above it, because the scale reads low, and it does so by a constant proportion - which is why both sets fall on the same straight line through the origin.

The regression (exactly what we computed). We want a correction that maps each raw reading ŷ onto the true mass. Every candidate is scored by its mean absolute error (MAE) over the n = 10 reference masses - the average gap between the corrected reading and the truth:

ŷi = corrected reading,   truei = reference mass,   n = 10

We fit two candidates to the same 10 masses by least squares and compared their MAE:

ModelEquationFitted numbersMAEMax error
No correction (raw)ŷ = rawnone50.7 g (16.1%)150 g
Two-parameter lineŷ = a + b·rawa = +2.846 g, b = 1.17284.9 g12.7 g
Through-origin (shipped)ŷ = k·rawk = 1.178045.1 g12.5 g

The two fitted models are tied on error (4.9 vs 5.1 g), but the two-parameter line carries a spurious +2.846 g intercept that over-reads light items (it would turn a true 0 g into 2.85 g, and a 53 g portion into 64 g). A load-cell span error is physically multiplicative, so the honest model is forced through the origin (a = 0), and its single gain k is the least-squares through-origin slope - literally the two sums from our data divided:

The result. The correction collapses the error from a 16% span to a flat few grams across the entire range. The chart below shows absolute error before and after: the raw error grows linearly with load (the signature of a span error, reaching 150 g at 1 kg), while the corrected error stays near zero and no longer trends with mass.

Formula output vs the truth (the whole calibration in one picture). The most honest way to show the engineering is to plot, for every object, three numbers at once: the raw reading the scale actually gave (before any software touched it), the exact output of our formula 1.178 × raw, and the true weight (the dashed diagonal, where reading = truth). The raw readings sit visibly below the diagonal - the scale under-reports - while the formula output lands on it. This is measured, corrected, and verified against ground truth in a single view: it is a real correction of a physical instrument, not a number we invented.

Zoomed to the light end (0 to 130 g). On the full-range plot above the lightest items are crushed into the corner, so here is the same data zoomed in. Even a 5 g object follows the exact same relationship - raw below the truth, formula output on it - and the 3 g item is the dead-zone case: the scale reads 0, so both the raw point and the formula output sit on the axis while the true weight is 3 g (no multiplicative fix can lift a zero).

ReferenceTrue (g)Mean raw (g)Raw error Corrected = 1.178×rawCorrected error
low_202115.8−5.2 (24.8%)19−2 (9.5%)
low_502925.4−3.6 (12.4%)30+1 (3.4%)
low_1009581.0−14.0 (14.7%)950 (0.0%)
low_150154135.0−19.0 (12.3%)159+5 (3.2%)
tuna162133.6−28.4 (17.5%)157−5 (3.1%)
yogurt209175.0−34.0 (16.3%)206−3 (1.4%)
water_362362301.6−60.4 (16.7%)355−7 (1.9%)
water_477477401.8−75.2 (15.8%)473−4 (0.8%)
water_708708590.4−117.6 (16.6%)695−13 (1.8%)
water_10621062912.0−150.0 (14.1%)1074+12 (1.1%)

Held-out validation: the water-fit constant generalises to unseen objects

The constant above was fit on water and food only. To prove it captures the scale's true physical span error rather than a water-specific curve, we applied the unchanged k = 1.178 to ten objects it never saw - a phone, a battery, a drinking glass, a remote, cardboard, a camera and some toys. It predicts their true mass to a mean absolute error of just 1.9 g (mostly within ±1-3%), across metals, glass, plastic and card. This is a genuine generalisation test, and refitting the slope on all 20 objects barely moves it (1.17804 → 1.17825), so the shipped constant is left unchanged.

Held-out objectTrue (g)Mean raw (g) Corrected = 1.178×rawError
tiny screwdriver30.00−3 (below detection floor)
tiny screwdriver ×254.250 (0%)
ball toy2320.624+1 (+4.3%)
carrot toy4437.6440 (0%)
cardboard4738.645−2 (−4.3%)
camera116100.2118+2 (+1.7%)
remote123103.0121−2 (−1.6%)
battery224194.2229+5 (+2.2%)
phone241202.4238−3 (−1.2%)
drinking glass289242.6286−3 (−1.0%)
Finding: a hard detection floor near 4 g. The 3 g screwdriver read 0 g on every one of five attempts. The scale simply cannot sense a load that small, and no multiplicative correction can recover a zero (1.178 × 0 = 0). Doubling it to 5 g was detected correctly. The honest specification is therefore a practical lower limit of ~5 g: below it the weight (and any calorie figure derived from it) is unreliable. This matters only for near-weightless items (a pinch of spice, a single sweet) and is surfaced as a limitation rather than hidden.
Final calibration & error budget.
  • Shipped formula (identical in the desktop demo, the ONNX edge exe, and the browser app): corrected_g = 1.178 × raw, applied right after the 5-sample median filter; the reverse-engineered byte decode is left untouched.
  • Validated on 20 objects (10 fit + 10 held-out): mean absolute error 3.6 g after correction, down from 34.0 g (16-20%) raw. On the held-out objects alone the error is 1.9 g, confirming the constant generalises rather than curve-fitting the training set.
  • Max error ≈ ±13 g over the full 5 to 1062 g range; as a fraction this is best mid-range (~1 to 3%) and only the smallest masses show a larger percentage (a few grams on a 20 g item).
  • Lower limit ~5 g: below a ~4 g detection floor the scale reads exactly 0 and cannot be corrected. The slope itself is stable (k = 1.178, unchanged when refit on all 20).
  • The residual is now dominated by the scale's own repeatability (mean spread ~10 g, up to 31 g at 1 kg), not by the fit: the correction has reached the hardware noise floor, and the median filter smooths what remains.
  • This is why the app deliberately shows a different number than the scale's LCD: the LCD shows the uncorrected raw value; the app shows calibrated true mass. Source data: scripts/eval/weights_cal.csv, fit by scripts/eval/weight_calibration.py.

Step 5 · The connection state machine

The BLE driver is a finite state machine: it discovers the scale, subscribes, streams and decodes packets, judges stability, and self-heals if the stream goes silent. Every packet is validated (length ≥ 8, AC header, and the additive checksum over bytes 2..6) before it is decoded, so a corrupt frame is dropped rather than shown.

DISCONNECTED SCANNING CONNECTING SUBSCRIBING STREAMING MOVING STABLE validate → decode → median STALE connect SWAN / RSSI GATT up notify ffb2 ≤ 2 g settled > 2 g moving no packet > 4 s reconnect gatt disconnect bad checksum → drop
StateEventActionNext state
DISCONNECTEDuser connectsstart BLE scanSCANNING
SCANNINGadvertiser SWAN / strongest RSSIopen GATT connectionCONNECTING
CONNECTINGGATT connectedenable notifications on ffb2SUBSCRIBING
SUBSCRIBINGnotify enabledstart the packet loopSTREAMING (MOVING)
STREAMINGvalid packetdecode, push to 5-sample medianMOVING or STABLE
STREAMINGbad header / length / checksumdrop the frameSTREAMING (no change)
MOVINGlast 10 samples vary ≤ 2 glatch a reading as settledSTABLE
STABLEa later sample varies > 2 greading is moving againMOVING
STREAMINGno packet for > 4 s (watchdog)tear down and re-scanSTALE → CONNECTING
anyGATT disconnectreset weight to 0DISCONNECTED
Why a state machine. A live sensor link is never "connected or not" - it drops, flickers, and goes silent. Modelling it as explicit states with a staleness watchdog (no packet for 4 s → automatic reconnect) and a stability gate (STABLE only when ten consecutive samples agree to 2 g) is what makes the reading trustworthy enough to multiply into a calorie figure. The manual-grams entry is the fallback path when the machine cannot reach STREAMING.

Live Demo: the End-to-End Application

The demo ties everything together: a webcam frame becomes a food class, a BLE weight, and a full nutrition breakdown, live. It runs the current Global and Israeli models behind the XGBoost arbiter, with a Gemini vision fallback for the cases the experts cannot handle. Every design choice below is made for one goal: a correct result in the room, under any camera and any light, as often as the score promises. Source: scripts/demo/caleyez_demo.py.

Runs on a basic CPU laptop (no GPU) - the ONNX edge build. For deployment on an edge device (8 GB RAM, a basic CPU, no GPU) we export both YOLO11l-cls experts to ONNX and run them with ONNX Runtime instead of PyTorch. A torch-free backend (scripts/demo/onnx_backend.py) feeds the arbiter the identical features, so routing is unchanged. We verified the ONNX models match the PyTorch ones to 0% top-1 mismatch on 200 test images per model, and the full pipeline (two ONNX models + the XGBoost arbiter) agrees on the routing decision for every image (max ΔP(israeli) = 0.013). Measured inference is ~0.35 s per analysis on a CPU (versus ~1-3 s for the PyTorch-CPU build), in a much smaller, torch-free executable. PyInstaller then packages it into a single standalone .exe with the ONNX models bundled inside - copy the folder to any Windows laptop and run it. Build: build_edge_onnx/.

The pipeline per capture

📷 frame (any res) center ROI crop letterbox to imgsz normalise 0..1 Global 132+ Israeli 13 20 features arbiter P(israeli) expert pick gate? Gemini USDA / local DB × grams

Preprocessing, step by step (animated)

This is exactly what happens to the pixels between the webcam and the network. Press play to watch one frame travel through the four preprocessing stages and into both experts.

ready
StageWhat happensWhy
1 · Center ROI cropCrop the central square of the frame (the green "place food here" box, about 60% of the short side).Deterministic and scale-consistent: the food fills a known fraction on every camera, with no silent failures. Replaces the old idea of auto-detecting a plate.
2 · Letterbox to imgszResize the square to the model input size (320 for Global, 224 for Israeli) inside Ultralytics predict().The model always receives the exact geometry it trained on, so a 720p and a 4K camera produce the same tensor. No aspect-ratio distortion.
3 · NormalisePixel values are scaled from 0..255 to 0..1 (the network's expected range).Matches training preprocessing exactly.
4 · Into both expertsThe same tensor is classified by the Global and Israeli models in a single forward pass each.One predict() per model, identical to how the arbiter's training features were generated, so routing behaves exactly as evaluated.

What we deliberately do NOT do (lessons from testing)

The preprocessing is intentionally minimal. Several "smart" steps were tried and then removed because measurement showed they hurt. Keeping the record is the honest engineering story.

TriedMeasured effectDecision
Gray-world white balanceDesaturated dominant-colour foods (a red pepper read as a beige pastry), because gray-world assumes the scene averages to neutral gray.Removed
GrabCut food segmentationOn a clean hummus image the prediction dropped from 96% to 92% wrong (malawach). The models are already background-robust (a pepper scores 99.97% on a dark or wood background), so segmenting was pure harm.Removed
CLAHE + test-time averagingAveraging raw and CLAHE softmaxes shifted the confidence values away from the distribution the arbiter was trained on, which broke routing (confident Israeli dishes were sent to the Global model).Removed
The principle. The models were trained with heavy augmentation and are robust to background, resolution and lighting on their own. The only safe preprocessing is the part that matches training exactly: a consistent crop, a letterbox resize, and a single forward pass. Every extra transform we added moved the live input away from the training distribution and cost accuracy. Restraint, verified by measurement, was the right answer.

Why a fixed center ROI

The crop is a fixed central square (the on-screen guide box), not an automatically detected plate.

ApproachBehaviourVerdict
Automatic (HoughCircles / GrabCut / contours)Depends on a clean plate, even light and a plain background; fails silently on clutter and returns a wrong crop (measured: it broke clean predictions).Unpredictable on stage
Fixed center ROI (chosen)A drawn "place food here" box; we always crop that square.Deterministic, scale-consistent, no silent failures

The deeper finding: camera angle, not background

Testing exposed something more fundamental than clutter. The Global model classifies a bell pepper at 99.97% even when it fills only a quarter of a dark-scale or wood background, so background and crop size are not what break it. What breaks it is the viewing angle: the training images are almost all side or eye-level views, while a top-down demo camera sees a red disc with a central stem, which is genuinely out of distribution and can read as chocolate cake.

How the system handles it honestly. This is exactly the case the confidence gate is for. On the top-down pepper the Global model scored only 39%, below its 0.55 gate, so the app flagged low confidence rather than asserting a wrong answer, and routes the image to the Gemini fallback (when a key is configured) which is not tied to the local training distribution. The practical mitigations are to angle the camera nearer eye level and to enable the cloud fallback for unusual views. Recognising that the limit is the training-data viewpoint, not the pipeline, is itself a lesson from testing.

Wiring the real arbiter (not a re-implementation)

The demo runs the V2 Israeli model (14 classes: the 13 dishes plus background) and computes the same 20 features the arbiter was trained on, in the same order, then loads the exact saved model. The 20th feature is i_p_background, the Israeli model's "this is not one of my dishes" probability, which is now the arbiter's single most important feature.

g = expert(global_model, roi, imgsz=320)   # top-5 conf, entropy, margin
i = expert(israeli_model_v2, roi, imgsz=224) # also reads P(background)
X = [g_conf1..5, g_entropy, g_margin,
     i_conf1..5, i_entropy, i_margin, i_p_background,
     conf_gap, conf_ratio, entropy_gap, margin_gap, both_unsure]
p_israeli = arbiter.predict_proba(X)[1]     # scripts/arbiter/arbiter_xgb.json (V2, 20 feats)
route_to_israeli = p_israeli >= 0.5
Why this matters. Because the features and weights are identical to training, the live router behaves exactly like the 97.3%-AUC V2 arbiter in the evaluation, not a look-alike. If the Israeli model is routed to but its top guess is background, the demo treats that as an abstain and falls back (never showing "background" as a food). The decision panel shows both experts' top guess, the live P(israeli), and the per-feature contributions, so routing is visible, not hidden.

The Gemini fallback: covering the cases no expert can win

About 11% of system errors are images where both experts are wrong (the both-wrong pool from the System Analysis tab). No amount of routing fixes those. The demo detects that situation and asks a cloud vision model instead, which is the honest way to push realized accuracy past the local ceiling on hard inputs.

TriggerConditionMeaning
Weak chosen expertchosen top-1 below its gate (0.55 global, 0.60 Israeli)The selected model is not confident enough to trust
Both experts unsureboth top-1 below 0.50 (both_unsure)The input is likely out of both label spaces
Arbiter undecidedP(israeli) within 0.15 of 0.50 (ARBITER_BAND)The router cannot confidently pick an expert
Israeli abstainsrouted to Israeli but its top-1 is backgroundThe Israeli model says "not my dish" -> defer instead of forcing a label

The two gates differ on purpose: the Global model spreads probability over 132 classes, so a modest top-1 is still meaningful, while the Israeli model has only 13 classes and must clear a higher bar to be believed. When either trigger fires, the ROI crop is sent to Gemini and its label drives the nutrition lookup. The UI marks these results so the source is never ambiguous.

UI and UX choices

ChoiceWhy
Tkinter desktop appThe system is an edge device: models, camera and BLE all run locally with no server. Tkinter ships with Python, starts instantly, and binds cleanly to the OpenCV camera loop and the BLE thread, which a browser UI cannot do without extra bridges.
Live decision panelTwo confidence bars (Global, Israeli) plus the live P(israeli) make the routing visible while you demo. The reviewer point that the router was unclear is answered on screen: you watch it decide.
"Why" force chart (TreeSHAP)For each capture the panel draws the exact per-feature contributions to the routing decision (bars pushing toward Global or Israeli), computed with TreeSHAP from the XGBoost arbiter. The decision is not just shown, it is explained on the spot.
Data-tagging flywheelType the true dish (autocompleted from all 145 class names) and every capture is logged: the cropped image (training-ready), both 512-D embeddings, and a verification row with each model's prediction and correctness. Demo failures become labelled fine-tuning data.
Embedded interactive log, not popupsThe day's meals live in a table inside the main window with running calorie and macro totals, colour-coded by which expert produced each row (blue global, teal Israeli). Add and delete update in place, so logging is part of the flow rather than a separate dialog.
Threaded inferenceInference runs on a worker thread so the camera preview and weight readout never freeze while the models think.
Weight colour stateThe big weight readout turns teal only when the BLE stream is stable (last ten samples within 2 g), so you capture on a settled reading, not a bouncing one.
Manual weight fallbackIf the BLE scale is not connected, a small "manual g" field is used automatically, so a flaky Bluetooth link can never block the demo. The app states which source is live.
API keys from the environmentUSDA and Gemini keys are read from environment variables, never hard-coded, so the public repo carries no secrets.
Result. The same pipeline that scores 86.2% on the held-out test now runs live, with the routing on display, robustness baked into the input path, and a cloud fallback for the inputs the local models genuinely cannot win. Source: scripts/demo/caleyez_demo.py.
CalEyeZ · single living project document · Raz Dvora & Roi Tzur · Shenkar.

The Web App: Edge AI in the Browser

The same two-expert pipeline that runs on the desktop also runs, unchanged, inside a phone browser - no install, no app store, no server doing the inference. A judge scans a QR code and the two CNNs plus the XGBoost arbiter execute on their own device. This tab documents how a browser becomes an edge-AI runtime, how it uses the device's CPU/GPU and memory, where the cloud is and is not used, and the hard limits of the platform. Repo: caleyez-web (public, GitHub Pages). Live: raz-dv-ee.github.io/caleyez-web · engineering console: /engineering.html · latency bench: /bench.html.

On-device, not cloud. The recognition never leaves the phone: the food image is turned into a class label entirely by code running in the browser tab. This is the direct answer to the panel's "make it a real edge product / no cloud" note - the model literally runs on the consumer device, and the app proves it by displaying the measured inference time (⚡ N ms · on-device edge) next to every result, so there is no hidden HTTP round-trip to explain.

How a browser runs a neural network on the edge

The two YOLO11l-cls experts are exported to ONNX (the same models the desktop ONNX build uses) and executed with ONNX Runtime Web (onnxruntime-web). ORT-Web ships the runtime as a WebAssembly (WASM) module, so the network's operators run as near-native compiled code inside the browser sandbox - this is what makes real CNN inference on a phone practical rather than a toy.

BackendWhat it usesWhenTrade-off
WASM (default)the device CPU, SIMD, single-threadedalways, unless opted out Widens the fp16 weights to fp32 at run time, so it makes the same top-1 decision as desktop (only ~10−3 probability drift). Chosen as default.
WebGPU (?gpu=1)the device GPU, fp16 computeopt-in via URL Faster steady-state, but a ~2.6 s one-time shader compile (hidden behind the loading splash) and fp16 rounding degraded a few classes on some mobile GPUs - so it is not the default.
We deliberately default to the CPU/WASM path. On mobile GPUs the fp16 WebGPU compute lost accuracy on borderline foods (pizza/hamburger), so correctness beat raw speed. WebGPU stays available for benchmarking via ?gpu=1.

fp16 vs fp32, and why it matters here

A neural network is millions of numbers (the weights). fp32 ("single precision") stores each weight as a 32-bit floating-point number; fp16 ("half precision") stores it in 16 bits. Half the bits means half the memory and faster movement through the processor, but lower numeric precision: fp16 can represent far fewer distinct values, so very small differences get rounded away. We publish the models in fp16 so each download is roughly half the size (about 25 MB instead of about 50 MB) and they sit comfortably in phone memory. The two backends then treat those fp16 weights differently, and that is the crux of our default:

fp32 (single precision)fp16 (half precision)
Bits per weight3216
Size / memorybaselineabout half
Numeric precisionfulllower (more rounding)
In CalEyeZWASM widens fp16 to fp32 before computing, so the arithmetic stays full precision and makes the same top-1 decision as desktop (~10−3 probability drift, not bit-identical)WebGPU computes directly in fp16: faster, but the rounding can flip a borderline prediction

So the phone downloads small fp16 files either way; on the default CPU path each weight is widened to fp32 for the computation. Widening does not recover the bits fp16 dropped - a widened fp16 value is the same rounded number with trailing zeros, no new information - it just stops the millions of multiply-adds from re-rounding at every step, which is where fp16 compute actually loses accuracy. The only difference left from desktop is a ~10−3 probability drift from rounding the weights to fp16 once at export; the decision is identical. That single distinction is why CPU/WASM is the default and WebGPU is opt-in.

A concrete example - so why do the zeros help at all? They don’t, directly - you’re right. Picture fp16 as a calculator that shows 4 digits and fp32 as one that shows 8. The weights are 4-digit either way, so padding zeros adds no accuracy to them. What changes is the running total a layer builds from thousands of multiply-adds. On the 4-digit calculator, 1000 + 0.04 = 1000 (the 0.04 is below its precision, so it vanishes); do that a hundred times and you still read 1000 when the truth is 1004. The 8-digit calculator keeps 1000.0400 → 1004.0000. So fp16 arithmetic quietly drops small activations that can decide a borderline food. Widening to fp32 loads the same 4-digit weight into an 8-digit register so every step afterward has room - the zeros are just the loading; the win is the arithmetic. The desktop uses the same 8-digit (fp32) arithmetic, which is why the browser matches its decision; the only residual gap is 4-digit vs 8-digit inputs (~10−3), which never piles up. “Just compute in fp16” means doing every step on the 4-digit calculator, so the rounding compounds thousands of times - and that is what flips pizza vs hamburger on the WebGPU path.

What a “mantissa” is, and the multiply that overflows it

A floating-point number is stored as sign × mantissa × 2exponent. The mantissa holds the significant digits, and the number of mantissa bits is what sets the precision. fp16 has 10 mantissa bits (~3-4 decimal digits); fp32 has 23 (~7 digits). The exponent handles magnitude, so fp16 isn’t short on range (it reaches 65,504) - it’s short on digits. That is the “room to grow” you need: not room for bigger numbers, room for more significant digits.

Watch it in one multiply-accumulate - the operation a CNN does millions of times. Multiply a weight by an activation, each stored to ~4 digits (fp16):

weight 1.234  ×  activation 5.678  =  7.006652   ← the exact product needs 7 digits
   fp16 (10-bit mantissa) → rounds back to 7.007     ← the tail .000652 is chopped
   fp32 (23-bit mantissa) → keeps       7.006652     ← all 7 digits fit

In bits: two 10-bit mantissas multiply into a result up to ~20 bits wide - fp32’s 23-bit mantissa has room to hold it, fp16’s 10-bit does not, so fp16 rounds it away at every step. That is exactly your intuition - the product of two 16-bit numbers wants ~32 bits, and only fp32 keeps them. A layer sums thousands of these products, so in fp16 the chopped tails compound; in fp32 they stay. Widening the fp16 weights to fp32 before computing gives each intermediate product that room - which is the whole point of computing at higher precision than you store.

So why is the leftover gap only ~10−3? It falls straight out of the mantissa width. (1) fp16 rounds each weight by at most half a mantissa step - near 1.0 that’s 2−115×10−4 relative. (2) A logit z = Σ w·x inherits that: worst case ~5×10−4, usually less (random errors cancel). (3) Softmax passes it through ~1:1 - e.g. top logit 8.000 vs runner-up 6.000 gives p = 1/(1+e−2.000) = 0.88080; nudge the margin to 2.004 and p = 0.88121 - a 4×10−4 shift. The measured max over all rows/classes is ~10−3. The top-1 only flips if the two best probabilities sit within 10−3 of each other; real margins are ~0.1+ (0.88 vs 0.12), so it never crosses - which is why the parity test found 0 top-1 mismatches.

One weight, followed end to end

The whole story on a single number - how an fp32 weight is cut to fp16, padded with zeros, and how the calculation refills those zeros and still lands on the same answer as the full model:

1 · Cut to fp16 (at export). trained weight 0.71341827 (fp32) → stored as 0.7134 (fp16). The low digits …1827 are dropped for good - the only precision ever lost.
2 · Widened in memory (at load). to compute, 0.7134 goes into a 32-bit slot as 0.71340000 - the freed low bits are filled with zeros. No information added; still 0.7134.
3 · The arithmetic fills the zeros. multiply by an activation 0.8207: 0.7134 × 0.8207 = 0.58548738 - the new digits 8738 land exactly in the slots that were zero. fp16 re-rounds them away (0.5855); fp32 keeps them.
4 · The numbers drive the logit. a neuron sums thousands of such products; keeping those tails (fp32) instead of chopping them every step (fp16) makes the summed logit match the full model - here 8.004 vs the full-fp32 model's 8.000.
5 · Same decision; drift blown away. softmax vs a runner-up logit 6.000: P = 1/(1+e−2.004) = 0.88121 vs the full model's 0.88080 - a 4×10−4 gap. The winner leads by tenths (0.88 vs 0.12), so a 10−3 nudge can't cross it.

So the light fp16-shipped model reaches the identical decision as the heavy fp32 model - the only thing that differs is the third decimal of the probability, which never decides an answer. That is why we can ship the small file and still be desktop-exact where it counts.

The options we actually weighed

Two independent choices: what format to ship the file in, and what precision to do the math in. Here is every end-to-end option and what it would cost:

Option (ship → compute)DownloadAccuracySpeedVerdict
fp32 file → fp32 math~100 MB the full-precision referenceCPU, ~0.6 s Rejected: doubles the download for no visible gain - it ends up computing in fp32 anyway (same RAM), and the fp16 version already reaches the identical decision.
fp16 file → fp32 math (chosen, default)~50 MB same top-1 decision (~10−3 drift)CPU, ~0.6 s Chosen: half the download, desktop-identical decisions. Widening to fp32 is free and keeps the arithmetic clean.
fp16 file → fp16 math (WebGPU, opt-in)~50 MB can flip borderline foodsGPU, fastest Opt-in only: fp16 compute accumulates rounding; it flipped pizza/hamburger on some mobile GPUs. Behind ?gpu=1 for benchmarking.
int8 file (quantized)~25 MB too coarse for food texturefast Rejected: 8-bit steps are too coarse and flip the subtle textures that separate look-alike dishes.
Why not just ship fp32, then? Because after widening, fp16→fp32 gives the same RAM, the same fp32 arithmetic, and the same decision as shipping fp32 - only the download is halved. Shipping fp32 would cost 50 MB extra for a ~10−3 probability difference that never changes the answer. The fp16-file / fp32-compute option isn’t a compromise; it strictly beats shipping fp32 for a phone.

Memory and CPU: what the phone actually does

Two fp16 ONNX models are downloaded once: Global ≈25 MB (320², 132 classes) and Israeli ≈25 MB (224², 13 classes + background) - ≈50 MB total, then HTTP-cached so later runs are instant. At run time WASM upcasts fp16→fp32, so a few hundred MB of working memory is touched during inference; the app targets devices with ≥2 GB RAM.

The CPU matters as much as the memory. Because the default path runs on WebAssembly, the actual matrix maths happens on the phone's CPU, so the processor's speed sets the inference time directly: a recent flagship CPU finishes both models in about 0.6 s, while an older or budget CPU is proportionally slower for the exact same work. This is why the models are sized deliberately (fp16 weights, 320/224 inputs, single-threaded WASM) to stay interactive on mid-range phones rather than only flagships, and why the app prints the measured time on every result so the device's real capability is visible rather than assumed. A phone with plenty of RAM but a weak CPU will still run correctly, just with a longer wait per photo.

WASM memory is outside the JS garbage collector. The tensors ORT allocates for each inference live in the WASM (and, on WebGPU, the GPU) heap, which the browser's garbage collector does not reclaim. After every capture the app explicitly disposes the input tensor and the run's output tensors (dispose(tg,ti,go,io)); without this the heap would grow with every photo and eventually crash the tab - the risk is largest on the WebGPU path where output tensors hold GPU buffers. A one-shot warm-up inference on load also compiles the kernels ahead of the first real photo, so the cold-start cost is paid behind the splash, not in front of a judge.

Bit-for-bit pipeline parity with the desktop

The browser preprocessing is written to match scripts/demo/onnx_backend.py so the two runtimes agree on the answer. Central ROI square (0.60) → resized on a canvas to each model's input (imageSmoothingQuality:'high') → ÷255, RGB, CHW, and no ImageNet normalisation (the ÷255 is the only scaling - verified on the desktop as the exact preprocessing). This was validated at 0% top-1 mismatch against the reference pipeline.

📷 frame ROI 0.60 resize 320 / 224 ÷255 · CHW Global fp16+ Israeli fp16 20 features XGBoost (JS) gate / route Gemini? nutrition × grams
"That's not image processing, you just ran a model." It is image processing, and it is not optional. Before the CNN sees a single number, the pixels are cropped, resampled, split into channels, and normalised in code - and getting any of those steps wrong changes the prediction. The engineering console shows every step live with the real numbers from the current photo; the same steps are laid out below on a representative pixel.
image-processing stepworked example
1capture source frame1920 × 1080 px
2centre-crop ROI (60%), discard the outer 40% (plate / background)648 × 648 px at (636, 216)
3resample to each model's input (bilinear, high quality)→ 320 × 320 and 224 × 224
4read RGBA bytes, drop alpha → RGB (0-255)a pixel = (183, 92, 47)
5normalise ÷255: byte → float in [0, 1](183, 92, 47) → (0.718, 0.361, 0.184)
6pack HWC → CHW planar tensor[1, 3, 320, 320]
7verify range over the whole tensormin / max / mean all in [0, 1]
No ImageNet normalisation - on purpose. A typical torchvision pipeline also subtracts a per-channel mean [0.485, 0.456, 0.406] and divides by a std [0.229, 0.224, 0.225]. CalEyeZ deliberately does not: the ÷255 above is the only scaling. This was not a guess - it was recovered by testing which normalisation reproduces the training-time behaviour, and the browser pipeline was then verified to match the desktop at 0% top-1 mismatch. The model is only ever as good as the image processing feeding it.

The XGBoost arbiter, re-implemented in JavaScript

ORT-Web has no TreeEnsemble operator, so the router cannot run as ONNX in the browser. We instead export the trained booster to a flat JSON (arbiter_trees.json) and walk the trees in JavaScript. This is copying, not retraining, and not an approximation - and below is exactly how, in plain terms.

The whole idea in one picture. The arbiter is 400 tiny yes/no flowcharts (decision trees). For one meal, each flowchart asks a couple of questions about the two experts’ confidence numbers (e.g. “is the Israeli model’s ‘not-mine’ score below 0.00005?”) and ends on a leaf holding one small vote. Add up all 400 votes plus a fixed starting number (the base) → a single score in log-odds. A sigmoid turns that score into a probability 0-1 = P(this meal is Israeli). Above 0.5 → route to the Israeli expert.

The math (two lines)

1. Add the votes. Each tree fk sends the 20-feature vector x to one leaf and returns that leaf’s vote. Sum the 400 votes plus the base margin b0:

2. Squash to a probability. The sigmoid turns the log-odds score into P(israeli):

That is the entire model - a weighted show of hands, then a sigmoid. Nothing is learned in the browser; the votes and questions were fixed at training time.

Converting it to the browser - 3 mechanical steps

The export script scripts/arbiter/export_arbiter_trees.py does exactly this and nothing more:

  1. Dump the trees. XGBoost’s own booster.get_dump("json") writes all 400 trees as JSON (feature, threshold, children, leaf values).
  2. Flatten each node into a tiny array the browser can read: an internal node becomes [feature#, threshold, go-if-yes, go-if-no]; a leaf becomes [vote].
  3. Compute the base. The starting number is the log-odds of the overall Israeli rate: base = ln(p/(1−p)) with p = 0.21763562−1.2795.

So one node in the file literally reads "0":[14, 0.000047, 1, 2] = “at the root, look at feature 14 (i_p_background): if it’s below 0.000047 go to node 1, else node 2.” Walking the file is then a dozen comparisons per tree:

function arbiterP(f){              // f = the 20 features
  let m = ARB.base;                // start at the base log-odds (-1.2795)
  for (const tree of ARB.trees){
    let n = tree["0"];             // root node
    while (n.length > 1)           // length 4 = internal, length 1 = leaf
      n = tree[ Math.fround(f[n[0]]) < n[1] ? n[2] : n[3] ];  // [feature,threshold,yes,no]
    m += n[0];                     // add this tree's vote (the leaf value)
  }
  return 1 / (1 + Math.exp(-m));   // sigmoid -> P(israeli)
}
Why Math.fround matters (and how we verified it). XGBoost compares in 32-bit floats internally, so the JS casts each feature with Math.fround before the < threshold test. The export script re-derives arbiter_trees.json from the trained arbiter_xgb.json and checks it against the Python model on all 32,136 held-out rows: max probability gap 3.8×10−7 and 0 routing disagreements. (Without Math.fround, ~0.08% of borderline routes could differ from the desktop.) The routed probability then feeds the same gates as desktop (GATE_G 0.55, GATE_I 0.60, both-unsure 0.50, arbiter-band 0.15), so browser and desktop make the identical call.

Latency, measured and shown

Inference is timed with performance.now() around the two run() calls and printed on the result as ⚡ N ms · on-device edge (and in the engineering console header). On a Pixel 10 Pro the warm inference is ≈0.6 s for both models + arbiter on the WASM path. Beyond honesty, this number is a demo argument: a live-updating on-device timer makes it self-evident that recognition is not a cloud call.

Where the cloud is used (and where it is not)

Recognition is 100% local. Two things legitimately use the network, and both go through a Cloudflare Worker so that the API keys are stored as Worker secrets and never appear in the public web-app repo. The single worker routes by HTTP method: GET = USDA, POST = Gemini.

ServicePurposeWhy a WorkerNotable engineering
USDA proxy (GET)nutrition for foods not in the local DB keeps the free USDA key server-side; 30-day edge cache Smart filter: rejects junk descriptions (juice/dried/sauce…), scores by primary-name and token overlap, treats cooking words as stop-words, and penalises ALL-CAPS branded/restaurant entries - so "apple"→apple, not apple juice, and "grilled salmon"→salmon. ?debug=1 returns the ranked candidate list for the engineering console.
Gemini vision (POST)identify the food when the on-device system is unsure keeps the Gemini key server-side; multimodal call Mirrors the desktop demo's fallback: when the confidence gate / arbiter band says "not sure," the ROI image is POSTed to gemini-flash-latest with the demo's exact prompt, and the returned food label overrides the local guess (marked with a 🔮 chip). Also available as a manual override button.

Cooking method, portion sub-types, and evidence-based calories

Calories change drastically with preparation, so the app offers a cooking-method choice (raw / boiled / grilled / fried / deep-fried) - but only for foods where it makes sense (an apple gets no dropdown; a steak does), driven by a per-class metadata file (foodmeta.json). A steak can also be refined to a specific cut (filet mignon, ribeye…), each carrying its own curated per-100 g macros.

The multipliers are measured, not invented. The cooking-method factors are derived empirically from USDA raw-vs-cooked food pairs (scripts/eval/cooking_multipliers.py): for each pair the per-100 g cooked/raw kcal ratio is the multiplier, aggregated with a sample count and 95% confidence interval. Because the scale weighs the cooked food, per-100 g-cooked is exactly the right basis. Result: raw ×1.0, boiled ×1.04, grilled ×1.17, fried ×1.47, deep-fried ×1.99 - each shown in the engineering console with its n and CI.

Weighing: the BLE scale in the browser

Portion mass comes from the same reverse-engineered SWAN scale, read over Web Bluetooth: connect to device SWAN, subscribe to notify characteristic ffb2, and decode the 8-byte packet (weight = low + (high|carry)×256, additive checksum over bytes 2-6). See the BLE Scale tab for the full protocol. When no scale is present, grams are entered manually. Web Bluetooth is Chromium-only, which is the app's sharpest platform limit (below).

The engineering console: the whole pipeline, exposed

/engineering.html is a diagnostic view that traces one capture end to end: the preprocessing thumbnails, both experts' top-5 with entropy/margin/P(background), the 20-feature arbiter vector, the arbiter P(israeli) with every gate shown PASS/FAIL, the USDA smart-filter candidate table (scores + reject reasons), the cooking-multiplier math with n/CI, a live per-byte BLE packet decode with checksum verification, and a compatibility/requirements section. It exists so the engineering is legible as structured evidence, not a black box.

Limitations of the browser platform

Running on the open web means the browser and hardware set real limits. These are stated plainly rather than hidden, and are also surfaced in the app's own compatibility section.

LimitCauseEffect / mitigation
BLE scale is Android-Chrome onlyWeb Bluetooth is unsupported on iOS (Safari and Chrome-iOS both use WebKit) and FirefoxOn iPhone the scale cannot pair → the app falls back to manual grams. Best full experience is Android Chrome.
Memory / download≈50 MB of models + fp32 upcast at run time Needs ≥2 GB RAM; very old low-memory phones may struggle. Models cache after first load.
CPU speed sets the latencythe default WASM path runs the inference on the phone CPU A flagship CPU gives about 0.6 s; an older/budget CPU is proportionally slower for the same work. The models are sized (fp16, 320/224 inputs) to stay interactive on mid-range phones, and the measured time is shown on every result.
WebGPU accuracyfp16 compute rounding on some mobile GPUs Left off by default; CPU/WASM (fp32-accurate) is the default path.
Network for nutrition / GeminiUSDA and Gemini are cloud services Recognition works fully offline after first load; only the nutrition lookup and the "unsure" Gemini fallback need connectivity.
Camera needs HTTPSgetUserMedia requires a secure context Served over HTTPS via GitHub Pages; fine in practice.

Hosting architecture

The app repo (caleyez-web) is deliberately tiny - HTML/JS only, no models - so GitHub Pages deploys it in seconds. The ≈50 MB of ONNX models, the arbiter trees, the class-name maps, foodmeta.json, and cooking_multipliers.json are hosted in this repo under /webmodels/ and fetched cross-origin (CORS-enabled) via MODEL_BASE. Keys live only in the Cloudflare Worker as secrets. Net result: a phone loads a few kB of HTML, pulls the models once from a CDN, and thereafter runs an entire multi-model food-recognition system locally.

In one line. CalEyeZ's web app is the desktop edge pipeline - two ONNX CNNs + a JS XGBoost arbiter + BLE weight - recompiled to run inside a phone browser on the device's own CPU (or GPU), with only nutrition lookups and an optional "unsure" Gemini fallback touching the network, each keyed behind a Cloudflare Worker.
CalEyeZ · single living project document · Raz Dvora & Roi Tzur · Shenkar.

Points Raised in Review and How We Addressed Them

The points raised at the interim review, each paired with the concrete change made in response. Nothing here is rhetorical; every response points at a tab with data.

Model and system architecture

Point raisedResponse
Some concepts, notably the routing component, needed clearer explanation. The Arbiter tab now defines it precisely, and names it correctly: it is an arbiter (it runs both experts and adjudicates), not a router. It reads no pixels, only the two experts' confidence behaviour, and answers one binary domain question. Every feature has its formula, and the old "it just defaults to the big model" behaviour (95% of decisions) is shown and contrasted with the new arbiter.
The workflow was hard to follow end to end. The pipeline was rebuilt around one reproducible script per stage (flatten, train, generate arbiter data, train arbiter, evaluate, analyse) with a single living document. Each stage's code is shown in its tab.
The classes are not balanced, which can impair performance. Acknowledged and addressed in the Dataset tab: imbalance is reported per class, the smallest classes are flagged, and class-aware sampling plus heavy augmentation compensate. Per-class recall is published rather than hidden.

Testing and validation

Point raisedResponse
There was a gap between the planned test validation and what was actually executed. The Validation & Risks tab now closes that gap: every test is executed and its evidence reported (held-out splits, ROC-AUC, error decomposition).
The connection between system goals and the test plan needed deepening. Added an explicit risk → test → evidence table. Each goal and risk maps to one specific test and its measured result.
Every incorrect food identification should map to a specific test. The System Analysis tab decomposes every single error into routing error (4.91%) vs both-models-wrong (11.34%), with per-class recall, so no failure mode is untested.
Lessons from the tests should be sharpened and tied to the final model's improvement. The Then vs Now tab traces each lesson (leakage, router defaulting, calibration hacks) to the concrete fix and the resulting metric change.
Net effect. The system did not just get a higher number; it got an honest one. Leakage was removed, the old defaulting router was replaced by a real arbiter (a domain detector) and fully explained, every error class is tested, and class imbalance is reported instead of hidden.
CalEyeZ · single living project document · Raz Dvora & Roi Tzur · Shenkar.

Israeli Model V2 (data + open-set retrain)

V2 attacks the system's biggest weakness: on the Israeli domain the model can reach 92.3% (its test-set ceiling), but the system only realizes 60.8%. The ~31-point gap is almost all routing error, because the original Israeli model is overconfident on non-Israeli food, so the arbiter cannot trust its confidence. V2 fixes the data, not the hyperparameters.

Governing principle. Every training hyperparameter is held identical to V1. Only the data and the run name change. That makes any metric difference attributable to the data and the open-set class, not to hyperparameter tuning, a clean and defensible A/B.

System results: before vs after V2 (the bottom line)

Overall system metric (held-out test)Before (V1)After (V2)Change
System top-1 accuracy83.8%86.2%+2.4
Israeli-domain realized accuracy60.8%77.9%+17.2
Global-domain realized accuracy86.8%87.3%+0.5
Routing ROC-AUC0.9330.973+0.040
Israeli routing recall60.8%79.2%+18.4
Gain over always-global baseline+5.8+8.8 pts77% of the way to oracle

Same two models' label spaces, same test protocol, same oracle ceiling (~88.8%). The entire gain comes from the open-set retrain making the Israeli model's confidence trustworthy, recovering images the arbiter used to misroute. Full method below; the same numbers also appear in the Arbiter tab.

Change 1 · More data for the data-starved weak classes

We targeted classes by accuracy, not image count (jachnun had the fewest images yet scored 100%, so it was not the problem). Real images were collected, then de-duplicated byte-for-byte.

Classtrain beforetrain afterreason
sabich92435weakest (57%), data-starved
malawach16638675%, data-starved
meorav_yerushalmi13436983%, data-starved
bourekas_cheese11754084%, data-starved
jachnun89392added (already 100%, low yield)

Change 2 · A new "background" class (open-set recognition)

The decisive change. We added a 14th class, background, built from the non-Israeli foods the model used to mistake for Israeli dishes. It lets the model answer "this is not one of my dishes" instead of confidently forcing one of the 13. Crucially it is sourced split-aligned: background train from the global train split, background val from global val, background test from global test. So a background test image is held out from both models (the global model never trained on it either), keeping every evaluation honest.

non-Israeli food background Israeli confidence becomes trustworthy arbiter can rely on it fewer misroutes

The look-alikes were chosen from the data: across the test set, 132 of the global images the arbiter misrouted to the Israeli expert were called hummus (creamy/beige foods like risotto, chicken_curry, macaroni_and_cheese), with smaller "sinks" at samosa (fried/dough), falafel (fried clusters), sufganiyah (donuts) and baklava (pastry). The background class is weighted toward those confusers (25 global classes). It is kept modest (400 train / 75 val / 75 test) so it cannot swallow real dishes.

Change 3 · Byte-for-byte dataset hygiene

The whole 14-class set was hashed (SHA-256) before training. This surfaced and removed a real, pre-existing problem.

CheckResult
New images duplicating the existing set0 (no contamination)
Cross-split leakage (same image in train and val/test)27 groups found and removed (mostly schnitzel, bourekas), 63 redundant copies quarantined
Cross-class duplicates (same image under two labels)0
Final settrain 6,001 · val 1,032 · test 1,467 · 14 classes · 0 leakage
Why this matters. Leftover train/test leakage inflates the reported accuracy. Removing it means the V2 numbers are honest, exactly the kind of rigor the reviewers asked for. Removed copies are quarantined (reversible), not deleted.

Training configuration and the reason for every parameter

Full retrain from the ImageNet-pretrained yolo11l-cls.pt (a fresh head over a pretrained backbone), not a fine-tune. Source: scripts/training/train_israeli_food_v2.py, run israeli_food_yolo11l_v2 (a new directory, so the V1 production weights are never overwritten).

ParameterValueWhy this value
imgsz224Matches V1, the arbiter feature generator, and the live demo, so the Israeli model's confidences stay on the exact scale the arbiter was trained on. Also the backbone's native pretrain size.
batch32Largest power-of-2 that fits an 8 GB RTX 3060 Ti at 224 with AdamW + AMP, while keeping BatchNorm statistics stable (≥16).
epochs / patience120 / 25120 is only a budget; early stopping on val top-1 (patience 25) decides the end, and best.pt keeps the best epoch.
optimizerAdamWRobust default for fine-grained transfer; decoupled weight decay generalizes better than plain Adam.
lr0 / lrf0.001 / 0.01Standard AdamW transfer LR: high enough to adapt the new head, low enough to preserve pretrained features; cosine-decayed to lr0×0.01.
warmup_epochs5Ramps the LR so the fresh classifier head does not destabilize the backbone in the first epochs.
dropout0.2The Israeli set is small and imbalanced; dropout in the head fights overfitting.
label_smoothing0.1Key for this project. Prevents 100% one-class outputs, giving better-calibrated confidences, which is exactly what the arbiter consumes and what the new background class needs to express honest uncertainty.
blur aug (custom)p=0.35Motion/defocus/Gaussian blur on 35% of batches, so the model is robust to a live webcam; 65% stay sharp.
hsv / degrees / scale / shear / perspectivesee scriptLighting and pose invariance: food has no canonical orientation and the demo camera is angled.
flipud / fliplr0.15 / 0.50Food is largely orientation-agnostic, so vertical and horizontal flips are safe augmentations.
erasing / mixup / mosaic0.40 / 0.10 / 0.0Erasing and mixup regularize; mosaic is OFF because it is a detection trick that harms whole-image classification.
amp / cacheon / offAMP ~2× speed at no accuracy cost at 224; cache off so the loaders rebuild from the changed data (no stale .cache).

How the background class feeds the arbiter (and why it is NOT a routing row)

The background class is an open-set / calibration device for the Israeli model, not a routing target. Two design rules keep it correct and leakage-free in the arbiter:

DecisionWhy
Skip background as arbiter rows (generate_arbiter_dataset.py excludes the class)The arbiter labels each row by domain (y = domain=="israeli"). Background images are non-Israeli food, so adding them as Israeli rows would literally train the arbiter to send global food to the Israeli expert. They are also copies of global images, which would otherwise appear twice with contradictory targets. Excluding them removes both problems.
Add i_p_background as a feature on every real held-out image This is the signal in clean form: the Israeli model's probability that the image is "not one of my dishes." A high value on a global image is a direct "route to global" cue. By the data-processing inequality it adds information the old logit summaries did not carry.
Background prediction = "Israeli declines" -> route global This is exactly the intended behaviour: when the Israeli model shouts background, the arbiter picks the global expert. It falls out of the feature automatically (high i_p_background + low Israeli dish-confidence -> global), with no contradictory labels.
Leakage-safe by construction. Because background val/test are sourced from the global model's own val/test, running the global model on them in the arbiter generator yields honest (not memorised) confidences, and no image used to train either model is used to evaluate it.

The mandatory pipeline after retraining

The arbiter must be regenerated. Its features come from the Israeli model's outputs, so a new model makes the old arbiter stale. The chain is non-negotiable:
python scripts/training/train_israeli_food_v2.py        # -> runs/israeli_food_yolo11l_v2
python scripts/arbiter/generate_arbiter_dataset.py     # rebuild features with the new model
python scripts/arbiter/train_arbiter_xgb.py            # retrain the arbiter (now includes the i_p_background feature)
python scripts/arbiter/evaluate_system.py              # honest end-to-end re-evaluation

Honest expectation

Two separate effects, neither of which raises the global model or the 88.4% oracle:

We will report per-class accuracy, the routing recall/precision, and the threshold trade-off curve so the gain is shown to be a measured operating point, not an arbitrary default.

Measured results (V2, on the held-out test set)

The retrain completed (best epoch 112). Per-class test accuracy on the classes we targeted improved sharply, and crucially the new background signal cleanly separates the domains.

ClassbeforeV2
sabich57%93.3%
malawach75%86.7%
bourekas_cheese84%90.0%
meorav_yerushalmi83%86.7%
falafel88%92.8%
background (new, OSR)n/a61.3% recall

The decisive signal: the Israeli model's mean i_p_background is 0.497 on global food vs 0.029 on real Israeli food, a near-clean "not-mine" separator. Feeding it to the arbiter lifted the whole ROC and recovered routing-lost images.

System metricV1 baselineV2change
Routing ROC-AUC0.9330.973the ROC lifted (real gain, not redistribution)
Israeli recall (routing)60.8%79.2%+18.4 pts
Global recall (routing)97.6%98.4%held
Israeli-domain realized acc60.8%77.9%+17 pts (routing gap ~halved)
Global-domain realized acc86.8%87.3%+0.5 pt
System top-183.8%86.2%+2.4 pts (now 77% of the way to oracle)
Why this is the principled win. Threshold tuning alone was near zero-sum (it only redistributes between domains). Raising the routing ROC-AUC from 0.933 to 0.973 is what actually adds net accuracy, and it came exactly as predicted: from information (a trustworthy Israeli confidence via the open-set background class), not from moving the decision threshold. The arbiter's top feature by gain is now i_p_background (0.289), well ahead of every logit summary.

V2 training curves and confusion matrix

V2 training curves
V2 per-epoch: train loss, val loss, top-1 and top-5 accuracy (120 epochs).
V2 normalized confusion matrix (val)
V2 normalized confusion matrix on the validation set (14 classes).

Reading the train-loss vs val-loss graph (what it actually means)

These two curves are the single most important diagnostic in training, and they tell a precise story. Both are the cross-entropy loss (with label smoothing): a number that is small when the model puts high probability on the correct class and large when it is wrong or unsure. They are computed on two different sets of images, and the comparison between them is what matters.

The gap between the two curves is the story. Train loss heads to 0 while val loss settles at ~0.5, so a gap opens up. A gap is normal and expected: any model fits data it has seen better than data it has not. What you watch for is the shape of the val curve:
  • Healthy (what we have): val loss falls, then flattens. The model keeps getting better on train without getting worse on unseen data. The flat val loss plus still-rising val accuracy (bottom-left, to ~0.89) means the extra epochs are harmless.
  • Harmful overfitting (what we do NOT have): val loss would bottom out and then turn back upward while train loss keeps falling. That U-shape is the signal to stop earlier. Our val loss never turns up, so the model is not overfitting in the damaging sense.

This is exactly why we do not deploy the last epoch: best.pt is saved at the epoch with the highest validation top-1 (epoch 112), not the lowest train loss. Selecting on the held-out metric, not the training metric, is what guarantees the shipped model is the best generaliser, and the patience=25 early-stopping rule exists to cut the run if val ever did start to degrade. The jagged look of the val curves is ordinary epoch-to-epoch noise on a finite validation set, not instability; the dotted "smooth" line is just a moving average to make the trend readable.

What the confusion matrix shows

Each cell is the fraction of images of a true class (column) that the model predicted as a given class (row); a perfect model is a pure diagonal. V2's diagonal is strong across all 13 real dishes (0.85 to 1.00: bourekas and jachnun at 1.00, sabich 0.94, malawach 0.93). The one deliberately weaker cell is background at 0.64: it is a broad catch-all of many non-Israeli foods, so it is the hardest class, and about a third of background images are still read as a look-alike dish (creamy foods leak toward hummus, fried toward samosa/falafel). That residual is acceptable because the arbiter does not need background to be the top-1 guess, it uses the probability i_p_background, which is high on those foods even when another class edges it out, and that is enough to route them to the global expert.

CalEyeZ · Israeli Model V2 · status: COMPLETE and ADOPTED. Model retrained (best ep112, val top-1 88.9% on 14 classes), arbiter regenerated + retrained with i_p_background (now its #1 feature), full system eval done, demo + System Analysis updated to V2. System 83.8% to 86.2%, Israeli domain 60.8% to 78.0%.

System Limitations and Future Work (V2 Architecture)

The governing distinction. System errors split into two pools: routing error (4.91%), where a correct expert existed but was not selected, and both-experts-wrong (11.34%), where no selection could have helped. A better arbiter and OSR attack only the first pool, so they raise the realized accuracy toward the 88.4% oracle but cannot exceed it. Only a stronger backbone and harder data attack the second pool, which is the only way to raise the oracle itself. V2 therefore pursues both axes deliberately.
4.91%
routing error (closes to oracle)
11.34%
both-wrong (raises oracle)
88.4%
current oracle ceiling

Limitation 1 · Classification backbone is not Pareto-optimal

The Global and Israeli experts use YOLO11l-cls. In its classification variant this is a legitimate classifier (the detection head is removed), but the C3k2 backbone is co-designed for multi-scale detection feature reuse, which is not the accuracy-per-FLOP optimum for single-label classification. On ImageNet-1k, hierarchical and isotropic classifiers occupy a stronger frontier.

BackboneParamsIN-1k top-1Note
YOLO11l-cls (current)~13Mgoodsingle exportable training and inference stack
Swin-T (Liu et al., ICCV 2021)~28M81.3%shifted-window attention, hierarchical
ConvNeXt-T (Liu et al., CVPR 2022)~28M82.1%modernized ConvNet, strong inductive bias
Honest scope. The expected gain is a few points of clean top-1, not a transformation, and the "smaller footprint" claim is workload-dependent (ConvNeXt-T has more parameters than the current backbone; the advantage is accuracy per FLOP and inductive bias). Because this is the only change that reduces the both-wrong pool, it is also the only change that can move the 88.4% oracle. V2 action: fine-tune ConvNeXt-T or Swin-T under the identical leakage-free 132-class protocol and re-measure the oracle.

Limitation 2 · The arbiter is information-starved (logits-only features)

The current arbiter consumes only scalar summaries of each expert's softmax: confidence, entropy, and margin. These are a deterministic, lossy function of the penultimate 1280-D embedding. By the data processing inequality, the embedding carries at least as much label-relevant information as any function of it:

I(domain ; conf, entropy, margin)  ≤  I(domain ; embedding_1280)

The information we discard is exactly the visual-feature overlap that defeats margin-based routing (falafel vs crab_cakes, sabich vs global dishes). A multimodal arbiter on the concatenated penultimate embeddings of both experts routes on visual evidence, not probability shape alone.

Global embed (1280-D)|| Israeli embed (1280-D) 2560-D fused vector PCA / small MLP arbiter P(israeli)
Constraint we must engineer around. 2560 dimensions against roughly 32k rows invites overfitting, and the Israeli embedding is least reliable out-of-domain. V2 action: reduce with PCA or a learned projection, regularize a shallow MLP head, and validate on the same held-out test rows. Ceiling reminder: this attacks only the 4.91% routing pool, so its maximum benefit is bounded by the oracle; it closes the gap, it does not raise it.

Limitation 3 · No native Open Set Recognition for out-of-domain inputs

The Israeli expert is overconfident on food it never saw, and we delegated the entire correction to the downstream arbiter. This treats the symptom. The Open Set Recognition literature provides root-cause fixes that force low confidence on non-Israeli inputs natively:

MethodMechanismReference
Background / "unknown" classAdd a class of global foods so the softmax has a sink for OODstandard practice
Outlier ExposureTrain against an auxiliary OOD set to flatten OOD confidenceHendrycks et al., ICLR 2019
Energy-based OODScore by free energy of logits; cleaner separation than softmaxLiu et al., NeurIPS 2020
OpenMaxRecalibrate logits with extreme-value theory to expose unknownsBendale & Boult, CVPR 2016
MSP baselineMaximum softmax probability as an OOD scoreHendrycks & Gimpel, ICLR 2017
Why this is the most elegant fix. If the Israeli expert natively reports low confidence on non-Israeli food, the arbitration problem nearly dissolves: a calibrated confidence gate becomes viable, and the learned arbiter is reduced to a tie-breaker rather than the sole defense. V2 action: retrain the Israeli expert with a background class plus Outlier Exposure, and report an OOD detection curve (AUROC, FPR at 95% TPR) as a first-class metric. Scope: this raises Israeli recall and shrinks routing error, but, like the embeddings arbiter, it operates inside the oracle and does not by itself lift the 88.4% ceiling.

A real attempt: fine-tuning on tagged demo images (and why it failed)

The data-tagging flywheel in the live demo produces labelled real-world captures, so the obvious next step is to fine-tune the Israeli expert on them. We tried it four times on the worst real-world class, hummus, with a safety harness that never touches the production weights, holds out part of the tagged set, and re-validates on the original clean test split. Every run made the model worse, and the regression guard correctly refused to recommend all four.

RunSetupClean test top-1 (was 92.26%)Change
115 epochs, AdamW, lr 1e-4, full fine-tune, 21 tagged images ×8 aug49.89%−42.4 pts
225 epochs, froze the backbone and trained the classifier head only23.37%−68.9 pts (collapsed: the framework reset the head to random and a frozen backbone could not retrain it)
320 epochs, lr 1e-4, 72 tagged ×2 aug66.21%−26.1 pts (least bad)
415 epochs, lower lr 5e-5, 72 tagged ×1 aug52.64%−39.6 pts
The diagnosis. The held-out real-world hummus accuracy actually rose during training, so the model did learn the new images, yet the clean test accuracy across all 13 classes collapsed. Two things happened at once. (1) Catastrophic forgetting: a small, single-class batch with no rehearsal of the original data overwrote shared features. (2) The decisive one, in the user's own words: the tagged hummus images were too visually similar to neighbouring classes. Overhead shots of a beige dip look like other spreads and pastes in the set, so training pulled those neighbours' decision boundaries into "hummus". The model relabelled its neighbours as hummus and their test accuracy cratered. A lower learning rate (run 4) did not help, because the core problem is the data, not the optimiser; and head-only training (run 2) failed for a separate reason: the framework reinitialised the classifier head to random weights, and with the backbone frozen that random head had no way to recover, so it collapsed.

What would actually fix it

FixWhy it addresses the failure
Rehearsal / replayMix the original balanced training data back in (not just the new class) so shared features are not overwritten. Naive fine-tuning on one class guarantees forgetting.
Freeze the backbone, retrain the head WITHOUT resetting it (or LoRA)The "head" is the small final classifier layer; the "backbone" is the feature extractor. Runs 1, 3, 4 fine-tuned every layer; run 2 froze the backbone but the framework reset the head to random, so it could not recover. The correct version keeps the existing head weights (or adds low-rank adapters), adapting only the classifier while leaving the learned features intact, with far less data.
Hard negatives for the look-alike neighboursSince the new hummus images resemble their neighbours, add labelled examples of those neighbours (or a contrastive / triplet objective) so the boundary is sharpened instead of swallowed.
Curate, do not just collectDrop ambiguous borderline captures; keep class-balanced sampling so the new images do not dominate. A heavy augmentation multiplier on a tiny, ambiguous set (run 1, ×8) makes the overfitting worse.
Early-stop on the clean-test metricSelect the checkpoint by overall held-out accuracy, not by the new class's accuracy, so a gain on one class can never be bought with a loss everywhere else.
Fix the root cause at the data levelThe binding constraint is training viewpoint, not one class. The durable answer is multi-viewpoint (including top-down) data for all classes plus a stronger backbone, not patching a single class after the fact.
The honest takeaway. The flywheel works as an experiment harness: it caught a real, repeatable failure before any production weight was touched. The lesson is that more labelled data is not automatically better. Data that is ambiguous with its neighbours, fed without rehearsal or a frozen backbone, actively degrades a model. This is exactly the kind of negative result the reviewers asked us to surface, and it sharpens the V2 plan: representation-preserving fine-tuning, hard negatives, and broader data, rather than naive single-class retraining.

V2 synthesis: which lever moves which number

V2 changeError pool attackedEffect on realized accEffect on oracle
ConvNeXt-T / Swin-T backboneboth-experts-wrong (11.34%)upraises it
Embeddings arbiter (2560-D)routing error (4.91%)up, toward oracleunchanged
OSR on the Israeli expertrouting error + Israeli recallup, toward oracleunchanged
Conclusion. These limitations are architecturally sound. The disciplined reading is that the embeddings arbiter and OSR are necessary to realize the accuracy the current models already make possible, while a stronger backbone and harder, larger data are the only path to raise the oracle beyond 88.4%. V2 commits to both axes so that the two are not confused.
CalEyeZ · single living project document · Raz Dvora & Roi Tzur · Shenkar.

System Analysis V2 (after the open-set retrain)

This is the full system evaluation after the Israeli Model V2 retrain (the open-set background class plus the i_p_background arbiter feature). The original System Analysis tab is kept unchanged as the V1 baseline; this tab is the current production system, measured on the held-out test set of 11,352 images (regenerated datasets/system_evaluation.csv).

Headline: system top-1 83.8% → 86.2%, Israeli-domain 60.8% → 77.9%, routing ROC-AUC 0.933 → 0.973, all with the global domain holding and the oracle ceiling essentially unchanged. The gain is a genuine routing improvement, not a threshold trade.

Error decomposition (V2)

Every outcome on the test set, split into the three mutually exclusive buckets that tell you where the remaining errors live.

86.2%
11.2%
System correct: 9,781 (86.2%) Routing error: 301 (2.7%) Both models wrong: 1,270 (11.2%)
What each bucket means, and what fixes it.
  • Routing error 2.7% (301 images): a correct expert existed but the arbiter sent the image to the other one. This is the only bucket a better arbiter can recover, and V2 already cut it from the V1 4.9% down to 2.7% (the open-set signal). It is now a small pool.
  • Both models wrong 11.2% (1,270 images): neither expert was right, so no router could have helped. This is the real ceiling, and it only moves with better models or more data (a stronger backbone), not with routing. It defines the 88.8% oracle.
  • The takeaway: the arbiter side is nearly solved (2.7% recoverable left); the remaining headroom is in the experts, exactly as the Limitations and V2 tab argues.

Accuracy by domain (V2)

SliceImagesSystemAlways-globalOracle
Global domain9,96087.3%88.2%88.2%
Israeli domain1,39277.9%0.0%93.2%
Overall system11,35286.2%77.4%88.8%

V1 vs V2, side by side

Metric (held-out test)V1V2Change
System top-183.8%86.2%+2.4
Israeli-domain accuracy60.8%77.9%+17.1
Routing ROC-AUC0.9330.973+0.040
Routing error pool4.9%2.7%-2.2
Both-wrong pool11.3%11.2%~same (model-bound)

Top remaining system confusions (V2)

The misses left after V2, true class to predicted class on the test set. They are dominated by genuine visual look-alikes, which is the both-models-wrong pool, not routing.

TruePredictedCountNote
falafelcrab_cakes35brown fried balls, the classic confuser
chocolate_moussechocolate_cake12same ingredient family
apple_piebread_pudding10baked, similar texture
falafeltacos10both global; model error, not routing
hummusomelette10pale, creamy, similar plating
samosaspring_rolls9fried dough pockets
Reading these confusions. Almost all are dishes that genuinely look alike to a human too (fried balls, chocolate desserts, fried dough). They are both-models-wrong cases, so they sit in the 11.2% pool that a better backbone would address, not a better arbiter. The routing side is already near its ceiling.
CalEyeZ · System Analysis V2 · regenerated on the V2 system_evaluation.csv (11,352 test images).

Approaches We Tried and Rejected

Before settling on a BLE gravimetric scale for weight, we built and measured two other ways to get the mass of the food. Both worked in the lab and both were rejected for concrete, measured reasons. Keeping this record is the honest engineering story, and it is the direct answer to "why a scale and not just the camera?"

The thread that connects them. A camera is excellent at telling you what a food is. It is a poor instrument for how much it weighs. Every vision route to mass goes through a lossy chain (pixels to volume to density to grams, or a digital readout through OCR), and each link adds error and fragility. The winning move was to stop inferring and measure the mass at the source.

Approach A · Estimating weight from a single photo (volume to mass)

The idea: put a coin of known size in frame as a fiducial (an Israeli 1 shekel coin, 18 mm), use it to convert pixels to centimetres, recover the food's volume, then multiply by density to get grams. We implemented two independent volume methods and validated both against a gold standard.

Method 1 · Monocular depth (MiDaS)

Apple with coin fiducial
Input: a single top-down photo of an apple with the 1 shekel coin as the scale reference.
MiDaS depth volume pipeline
MiDaS depth map relative to the table, integrated over the apple mask. Coin gives px to cm (pixel size 0.0101 cm). Raw volume 722 cm³ collapses to a final 139.3 cm³ after height calibration.

A neural depth network estimates the height of every pixel above the table; summing height over the food's area gives a volume. It got the apple to 139.3 cm³.

Method 2 · Shadow geometry

Shadow-geometry volume pipeline
The panels show the original capture (with the coin fiducial and a hard single-source shadow), the detected shadow and can masks, and the measurements: coin to px/cm (50.34), shadow length 10.88 cm, height = L·tan(45°) = 10.88 cm, perspective-corrected top area 47.75 cm², giving volume 519.67 cm³.

With a 45° light, an object's height equals the length of its cast shadow. Multiplying the perspective-corrected top area by that height gives a volume without any depth network, purely geometry.

Validation: Archimedes water displacement (the gold standard)

Apple submerged in a measuring cup
The same apple fully submerged. The water it displaces is its true volume, here about 150 cm³, so MiDaS (139.3) was within roughly 7 percent.
The methods were actually accurate. Against displacement, MiDaS landed within about 7 percent. So the failure below is not that we could not measure volume, we could. The failure is what comes after volume.
Why we rejected it anyway.
  • Volume is not mass. Converting cm³ to grams needs density, and density varies wildly by food (a cup of salad versus a cup of peanut butter). At inference the density is unknown, so even a perfect volume gives a wrong weight. This is the fatal flaw.
  • It only worked in controlled conditions. It required a coin in frame, a single clean object, a plain background, and (for the shadow method) a hard directional light. A real plate of mixed food breaks all of these.
  • Errors compound. Fiducial detection, segmentation, depth or shadow estimate, then a density guess, each multiplies the next. The apple floated and had to be forced under for even the validation.

Approach B · Reading the kitchen scale's display by OCR

The next idea kept a real scale but avoided pairing it electronically: point the camera at the scale's 7-segment LCD and read the number with OCR (pytesseract, in food_app.py / new_gui_with_ocr.py).

Why we rejected it. Seven-segment OCR is brittle: general OCR engines are trained on normal fonts, not segmented digits; the LCD glares and washes out under room light; a slight camera angle merges or splits segments; and the value flickers as the reading settles, so a single frame is often wrong. It also still demanded that the display sit cleanly in the camera's view. We were adding a fragile vision problem on top of a device that already knew the answer digitally.

The decision: read mass at the source over BLE

Both rejected routes share one mistake: inferring a physical quantity the hardware already measures exactly. The scale computes grams internally, so instead of photographing its screen we reverse-engineered its Bluetooth Low Energy protocol and read the mass directly (see the BLE Scale tab). No density assumption, no fiducial, no lighting or glare dependence, gram-accurate every time.

ApproachLab resultWhy rejected
MiDaS depth volumeapple 139.3 cm³ (~7% vs Archimedes)volume needs density to become grams; controlled-conditions only
Shadow geometry volumecan 519.67 cm³same density problem; needs a coin and a hard shadow
7-segment OCR of the scaleworked on clean framesglare, angle, flicker, segmented-font OCR is unreliable
BLE gravimetric (chosen)exact grams at the sourcerobust, no inference, no assumptions
The principle, in one line. Use the camera for identity, never for mass. When a physical quantity is already measured by the hardware, read it directly rather than inferring it through a lossy proxy. That single decision is what made the weight side of the system reliable.
CalEyeZ · single living project document · Raz Dvora & Roi Tzur · Shenkar.

Training Lab: how learning actually happens

Three interactive toys that make the training terms concrete: how a model learns by gradient descent (and why the learning rate matters), why batch size changes the path, and what a backbone, a head, and freezing really mean. These are the exact levers behind the Israeli Model V2 retrain and the fine-tune failures.

1 · Gradient descent and the learning rate

A model "learns" by minimising a loss (how wrong it is). It repeatedly nudges its weights downhill along the slope (the gradient) of the loss. The step size is the learning rate (LR). The curve below is a simple loss bowl; the dot is the current weight. Press play and change the LR.

LR:
LR regimeWhat you seeWhy
Too small (≈0.1)The dot crawls, barely movesTiny steps; training would take forever / stall
Good (≈1.0)Slides smoothly to the bottomSteps are proportional to the slope; fast, stable convergence
High (≈1.9)Overshoots and zig-zags, still settlesEach step jumps past the minimum but the bowl pulls it back
Too high (>2.0)Bounces outward and divergesSteps overshoot more than the slope corrects; loss explodes (this is what too-high LR does in real training)
This is the whole game. Real networks do exactly this in millions of dimensions at once. lr0=0.001 in our training is small on purpose: it adapts the model without blowing up the pretrained features. warmup ramps the LR up gently at the start for the same reason.

2 · Batch size: why it changes the path

The true downhill direction is computed by averaging over data. A batch is how many images you average before each step. A small batch gives a noisy estimate of the slope (the path jitters); a large batch gives a smooth estimate (a cleaner path) but each step costs more memory. Drag the batch size and watch the descent path on a 2-D loss (the rings are contours).

Batch size:
BatchGradientTrade-off
Small (1-8)Noisy, jittery pathMore updates per epoch, low memory, the noise can even help generalisation, but unstable BatchNorm statistics
Large (128+)Smooth, direct pathStable and fast on a GPU, but uses much more memory and can generalise slightly worse
Why we used batch = 32. It is the largest power of two that fits the 8 GB GPU at 224 px, while still being big enough (≥16) to keep BatchNorm statistics stable. That is the real reasoning behind the number in the V2 training config.

3 · Backbone, head, and freezing

A classifier has two parts. The backbone is the deep stack that turns an image into a compact feature vector (it holds the general visual knowledge). The head is the small final layer that maps that vector to class scores. Learning flows forward (prediction) then backward (the gradient updates the weights). Freezing the backbone locks it so only the head learns. Toggle freeze and run a step.

ModeWhat trainsWhen to use it
Full fine-tune (no freeze)Backbone + head both updatePlenty of data; willing to risk catastrophic forgetting. Runs 1, 3, 4 of our fine-tune did this.
Freeze backbone, train headOnly the head updates; features stay intactLittle data; fast; protects general features. The right way to add a class cheaply.
Freeze backbone + reset head (the trap)Head starts random, backbone cannot adapt to itNever. This is exactly run 2 and the global fine-tune: the framework reset the head to random, the frozen backbone could not retrain it, and accuracy collapsed to near-random.
This panel explains our fine-tune failures. Toggle both checkboxes on: the backprop pulse stops at the frozen boundary and the random head has no way to recover, which is the 23% collapse from the Limitations & V2 tab. The fix is to freeze the backbone but keep the existing head weights (or use LoRA adapters), or to do a full retrain like V2.

4 · Logits, softmax, and what the arbiter reads

The final layer of each model outputs one raw score per class, the logits. They are unbounded (any real number) and do not sum to anything. Softmax turns them into probabilities that sum to 1, by exponentiating and normalising: p_i = e^(z_i/T) / Σ e^(z_j/T). The model's "confidence" is just the largest probability. Drag the logits and watch the probabilities, and the three numbers the arbiter actually consumes: top-1 confidence, margin, and entropy.

logit A B C D E temperature T
QuantityMeaningUsed by the arbiter as
logits (z)raw, unbounded class scores, straight out of the final layernot directly; they are turned into probabilities first
softmax probabilities (p)logits squashed to [0,1], summing to 1g_conf1..5, i_conf1..5, and i_p_background
top-1 confidencethe largest probability (how sure the model is)g_conf1, i_conf1, the gates
margintop-1 minus top-2 (how decisive the choice is)g_margin, i_margin
entropyspread of the whole distribution (low = peaked, high = unsure)g_entropy, i_entropy
temperature (T)divides the logits before softmax: high T softens, low T sharpensthe calibration lever discussed in Limitations & V2
This is the exact bridge to the Arbiter. The arbiter never sees pixels; it sees these softmax-derived numbers from both experts. Raising T flattens every probability (less confident); lowering T spikes the top class. That is why a network can be overconfident (peaky softmax) on food it has never seen, the very problem the open-set background class fixed.
CalEyeZ · Training Lab · interactive explainer of the training terms used across this report.

Real-World Image Validation

47 real photos of food taken with our own phones, sorted into class folders and run through the exact deployed pipeline offline. The folder name is the ground truth; none of these images were in training. Each tile is the evidence card the pipeline emits (the ROI it saw, both experts’ top-5, the arbiter’s decision). Click any card to enlarge.

47
photos tested
85%
top-1 accuracy
40 / 7
correct / wrong
156 ms
median latency

Capture provenance - these are our own photos

Every original keeps its EXIF metadata, traceable to the device and moment of capture, 2016-11-12 to 2026-06-07. Most were shot on a Google Pixel 10 Pro in 2026, after the models were trained. Nothing was scraped from the web.

Google Pixel 10 Pro ×29 Samsung Galaxy S22 Ultra ×7 Forwarded (EXIF stripped) ×6 Apple iPhone 13 ×2 samsung SM-G965F ×1 samsung SM-G935F ×1 LG G3 ×1
85% correct
Correct (40)
Wrong (7)

Accuracy by class

falafel1/1 · 100%
french fries2/2 · 100%
hamburger12/12 · 100%
sabich1/1 · 100%
bourekas cheese9/10 · 90%
ice cream3/4 · 75%
canned tuna8/11 · 73%
pizza4/6 · 67%

The 7 errors, by root cause

Induced degradation (on purpose)2
Far / top-down framing2
Within-domain look-alike1
Old low-res photo1
Routing miss (re-shared file)1
40 / 47 correct = 85.1% (local-only, no Gemini) · 95% Wilson CI [72.3%, 92.6%]. This is the floor: in the live system the confidence gate routes the low-confidence misses to the Gemini fallback, recovering several of them. The Israeli dishes (sabich, falafel, bourekas) exercise the arbiter’s routing - and it routes them to the Israeli expert correctly.

falafel

1/1 = 100%

french fries

2/2 = 100%

hamburger

12/12 = 100%

sabich

1/1 = 100%

bourekas cheese

9/10 = 90%

ice cream

3/4 = 75%

canned tuna

8/11 = 73%

pizza

4/6 = 67%

Failure modes and how I would fix them

Induced capture degradation (pizza → quesadilla)

Two pizza shots I deliberately darkened and colour-shifted collapsed to chicken_quesadilla. White-balance shift moves the colour statistics the CNN leans on.Fix: Stronger colour/temperature augmentation in training, a quick auto white-balance check at capture, and best-of-N frames in the live app.

Far / top-down framing (tuna → ice cream, ginger)

A distant, top-down open can fills little of the ROI and its round rim reads as a bowl or scoop.Fix: On-screen capture guidance (fill the box, ~45&deg; not top-down) and a multi-scale or detection-based crop so the food dominates the ROI.

Within-domain Israeli look-alike (bourekas → malawach)

The Israeli expert split two genuinely similar golden fried pastries.Fix: Hard-negative mining between visually-close Israeli classes and a few more examples per dish; a fine-grained gap, not a routing one.

Old low-res / re-shared images (ice cream → donuts, routing miss)

A 2015 low-resolution photo and a re-shared media file lost quality and metadata; rounded scoops read as ringed donuts and one tuna shot misrouted.Fix: Prefer recent full-resolution originals; these old/forwarded inputs are out of the deployment distribution and are kept only for honesty.
What this validates. On controlled capture the system is strong (hamburger 12/12, french fries 2/2, falafel and sabich routed correctly, bourekas 9/10). The errors are a handful of honest, explainable cases: inputs degraded on purpose, extreme framing, one fine-grained look-alike, and a couple of old/forwarded photos. Most low-confidence misses are exactly the cases the confidence gate sends to the cloud fallback.
CalEyeZ · Real-World Image Validation · 47 field photos through the deployed pipeline, folder = ground truth, EXIF = provenance.

CalEyeZ vs the Market - a calorie face-off

We put CalEyeZ head-to-head against the three tools people actually use, on the same physical meals: MyFitnessPal (manual database logging), Cal.ai and FoodVisor (AI photo estimators). Ground truth is the product label wherever one exists. Every number below is a real trial recorded in scripts/eval/calorie_comparison.xlsx. This is an honest pilot (small n), and we say exactly where each number comes from.

37%
CalEyeZ mean kcal error
49%
MyFitnessPal (manual)
64%
Cal.ai (AI photo)
~0.5 s
our latency vs 5-140 s

Mean absolute calorie error over the 8 meals all three tools were tested on. CalEyeZ is the most accurate - despite MyFitnessPal being handed the true weight and a hand-picked database entry, and Cal.ai being a giant cloud model.

The decisive test: does the calorie number follow the actual portion?

The whole game is portion. The photo apps emit a typical serving; MyFitnessPal offers preset units ("1 slice", "1 can"). Both are right only when your food happens to match their assumption. CalEyeZ measures the grams on a scale, so it tracks the real portion every time. We proved it with one food, two portions:

Same tuna, two portions. A full can (162 g) and a small 14 g ball. CalEyeZ read the ball at 15 kcal (3% error); Cal.ai reported 45 kcal (209% error) - it never leaves its "small serving of tuna" prior. On the plot CalEyeZ sits on the truth diagonal at both portions, while Cal.ai is a nearly-flat line with a floor: it is not measuring, it is guessing a standard serving.

Why this generalises to a steak. A photo shows the top surface, not the mass. A thin minute-steak and a thick ribeye can cover the same plate area yet differ 2-3× in grams - invisible from above. This is the identical self-occlusion / unknown-density wall that made us reject stereo and LiDAR depth (see Validation & Risks). A state-of-the-art cloud AI fails for the same physical reason as depth hardware: pixels carry appearance, not weight. A scale reads the weight directly.

The killer test: photograph the same food many times

Accuracy asks “how close to the truth?” - but there is a more basic question a measuring tool must pass first: repeatability. Give it the identical input twice, do you get the same answer? We took one plate of StarKist tuna and photographed it again and again, changing only the camera angle and layout - the food, and its weight, never moved.

11-87
Cal.ai kcal, same tuna
63%
Cal.ai variation (CV)
28-36
CalEyeZ kcal, same tuna
10%
CalEyeZ variation (CV)

Seven photos of one tuna plate (~31 g, truth ≈31 kcal at 104 kcal/100 g). Cal.ai’s answer swings from angle alone; CalEyeZ stays on the truth line because it reads the weight off the scale, not the pixels.

Cal.ai isn’t even self-consistent. The same tuna returned 11, 22, 28, 36, 44, 67 and 87 kcal - and once it renamed the dish “Tuna and Tofu Preparation.” A tool that gives seven answers for one input is not measuring; it is guessing a serving from whatever the camera happened to see.
CalEyeZ holds the line - and shows its work honestly. Six of seven readings sat at 28-36 kcal, dead on the truth. The one low outlier (14 kcal) was a nutrition-database blip from the cloud fallback, not a weight error - the grams were right every time. On the recognition side the on-device model was often unsure (shredded tuna looks like the Israeli dish meorav yerushalmi), so the confidence gate escalated to Gemini vision - and on one shot the on-device Global expert nailed it outright at 99%. That is the safety net working exactly as designed, not a failure.

Per-meal results - how close is each app to the truth?

The clearest view: for every meal, the reported calories of each app plotted right next to the grey ground-truth bar. The closer a coloured bar is to grey, the better. Below it, the same data as an absolute-error chart so small and large meals are comparable on one scale.

Reported total calories vs ground truth (grey), per meal. Ground truth is the product label where one exists. Note Cal.ai’s malawach (938 vs 393) and both-pizzas-425 overshoots, and MyFitnessPal’s tuna (309 vs 168) - each is a portion/serving guess, not a measurement.

The same eight meals as absolute % error from truth - lower is better.

The full log - every meal, every app, side by side

Each cell shows reported kcal with the % error from truth underneath. Green ✓ = the app closest to truth for that meal. “-” = the app was not tested on that meal (Cal.ai on 8, FoodVisor paywalled after one photo).

Meal Ground truth Truth source CalEyeZ MyFitnessPal Cal.ai
grapes621reference DB 6342% 6210% -
bell pepper70reference DB 5225% 3254% 3156%
cucumber26reference DB 1831% 1254% 249%
canned tuna - full can (162 g)168product label 1828% 30983% 12029%
french fries73recipe truth (home fries) 172135% 165126% 11152%
hummus613product label 37539% 53213% 7089%
Domino’s pizza236Domino’s IL data 33542% 18024% 42580%
Pizza Hut slice274Pizza Hut data 2615% 18034% 42555%
malawach393product label 4258% 3911% 938139%
canned tuna - 14 g ball (portion test)15product label 153% - 45209%
Mean absolute error (8 meals all three apps share) 37% 49% 64%

Per-row winners are split (CalEyeZ and MyFitnessPal each win four rows, Cal.ai two), but CalEyeZ has the lowest mean error because it never posts a catastrophic miss except french fries - and that one is a database-entry problem (generic deep-fried vs home fries), not a weight one. MyFitnessPal’s and Cal.ai’s wins come from a mature food database, which is fixable on our side; neither ever wins on measurement.

Recognition is not the same as nutrition

Cal.ai - brilliant recognition, unreliable calories. Cal.ai is a cloud vision-language model. It correctly named hummus, malawach and even distinguished home-made fries from fast-food fries - genuinely impressive. But its calorie output is a typical-serving prior: it returned an identical 425 kcal for both a Domino’s and a Pizza Hut slice, and stayed near a fixed value when we stacked or shrank a portion. Great recognition, wrong quantity - because it has no way to weigh the food. That is precisely the gap CalEyeZ closes.
MyFitnessPal - accurate only with full manual effort. We gave it the generous case: the user searches, hand-picks a database entry, owns a scale, weighs the food and types the grams. Even then it was less accurate on average and it took 40-140 s per meal versus our ~0.5 s. For pizza it wouldn’t even accept grams - only "1 slice", a hidden portion guess. Its real strength is a mature crowd-sourced database for common Western foods; ours is measured weight, local dishes, speed and automation.
FoodVisor - paywalled. Only one free photo (cucumber) before a subscription wall. The friction itself is the point: CalEyeZ runs free, on-device, with no account.

The axis nobody else wins: on-device, offline, instant

on-device
recognition (verified in airplane mode)
offline
works with networking off
free
no account, no subscription
private
photo never leaves the device

Cal.ai and FoodVisor stop working in airplane mode - empirical proof their inference is server-side; their 5-140 s latency is the network round-trip. CalEyeZ keeps identifying food with the network disabled, because the models run on the device itself (see Web App).

Honest limits of this pilot. Small sample (10 meals; Cal.ai on 8, FoodVisor on 1). Ground truth is a real label for tuna, fries, hummus, pizzas and malawach, but only a reference database for grapes/cucumber/pepper (flagged in the table). The one row where our number looks worst - french fries at 135% - is a database issue, not a weight one: our generic "french fries" entry is deep-fried while the meal was home fries; the scale read the weight correctly. We never tune the database to the test meals (that would be leakage), so the residual database error is reported, not hidden.
The one-line takeaway. Recognition can be solved many ways; calories can only be solved by measuring the weight. On the same meals CalEyeZ is the most accurate of the four, it is the only one whose number follows the actual portion, and it does it on-device, offline and in half a second. The competitors’ wins come from a mature food database (fixable on our side) - never from a better measurement, because they have none.
CalEyeZ · App-vs-Market calorie face-off · data: scripts/eval/calorie_comparison.xlsx · ground truth = product label where one exists.

Why CalEyeZ Is Electronic Engineering, Not Just Software

A common first reaction is "this is a software project with a camera." It is not. CalEyeZ is a closed loop from the physical world, through silicon, back to a physical-world decision. Take away any one layer and a real subsystem disappears. Here is that argument laid out.

The one-sentence case. Two analog sensors (a camera and a load cell) capture the world; their signals are conditioned with 1-D and 2-D digital filtering; the recognition runs as convolutions and matrix-multiplies on a parallel GPU in FP16; a decision-tree arbiter and a confidence gate turn that into an answer; and the whole thing is held together by code that drives real hardware. That is a sensing-through-silicon-to-decision pipeline, which is electronic engineering.

The pipeline, read as electronic-engineering stages

StageWhat physically happensThe discipline behind it
SenseA camera and a strain-gauge load cell turn light and force into electrical signals.Sensors & Actuators · Analog Electronics
ConditionMedian filter on the 1-D weight stream; white balance + CLAHE on the 2-D image.Signal & Image Processing
Compute on siliconTwo CNNs run as tiled matrix-multiplies and convolutions, in parallel, in FP16, on a CUDA GPU.GPU Accelerator · Computer Architecture
DecideAn XGBoost arbiter routes between the two experts; a confidence gate escalates when unsure.Practical ML · Applications in AI
Drive it in codeOOP classes, ring buffers and bounds-checks orchestrate the camera loop and the BLE device loop.Python for E&E · Software / C

Each course → its project → the CalEyeZ code it produced

This is the hard proof. For every course: the project I built in it, and the actual code in our final project that the course put there. Not a name and a checkmark, the real lines.

1

Python Programming for E&E Engineers · תכנות פיתון למהנדסי חשמל

Course 3600102 · Raspberry-Pi-centric, hardware-in-the-loop

What it put into CalEyeZ

The whole codebase is Python driving real hardware (webcam + Bluetooth scale) through the module library, exactly the syllabus model.

The CalEyeZ code

# scripts/demo/caleyez_demo.py - the module library + a hardware device
import cv2, numpy as np, pandas as pd
from PIL import Image, ImageTk
from bleak import BleakScanner, BleakClient   # BLE hardware, in pure Python
...
self.cap = cv2.VideoCapture(0)   # a physical camera
self._tick_camera()                # live frame loop
self._tick_weight()                # live weight loop from the BLE scale

Immediate impact

The course's thesis, "intelligent operation of any hardware component", is literally the live demo: a camera loop and a BLE device loop running together in one Python program.
2

Software - Programming in C · מבוא למדעי המחשב (C)

Course 3601865 · algorithms, structs, dynamic memory, intro to classes (OOP)

What it put into CalEyeZ

The demo is built as OOP classes (the course's closing topic), with a custom widget that inherits a base class, plus ring buffers and a hand-written median filter.

The CalEyeZ code

# scripts/demo/caleyez_demo.py - a class that inherits and overrides, and the app object
class AutocompleteEntry(tk.Entry):                 # inherits a base class
    def __init__(self, parent, textvar, options, **kw):
        super().__init__(parent, textvariable=textvar, **kw)
class CalEyeZDemo:
    def __init__(self, root):
        self.frames = deque(maxlen=6)   # fixed-size buffer = the array/struct discipline from C

Immediate impact

Pointers, dynamic memory and bounds-checking from C are the exact mental model for reasoning about GPU memory and array indexing later. This course is the floor the GPU work stands on.
3

GPU Parallel Hardware Accelerator · חומרה מאיצה (GPU / CUDA)

Course 3624550 · CUDA, warps, memory hierarchy, tiling, convolution, FP16

★ The beating heart. The two vision models exist only because a GPU runs convolutions and matrix-multiplies in parallel, the precise subject of this course.

What it put into CalEyeZ

Both YOLO11 models train and run on a CUDA GPU (RTX 3060 Ti) in FP16/AMP, the floating-point lecture put to work. A YOLO layer is tiled convolutions + matmuls, the primitives this course builds by hand.

The CalEyeZ code

# scripts/training/train_general_model.py - CUDA GPU + FP16
results = model.train(
    device = 0,          # the CUDA GPU
    amp    = True,       # mixed precision = the FP16 path from the floating-point lecture
)
# scripts/demo/caleyez_demo.py - a GPU forward pass (the "Deep Learning on GPU" lecture)
g = model_g.embed(roi, imgsz=GLOBAL_IMGSZ)[0]  # 512-D penultimate features, on the GPU

My course project → why it IS this engine

CUDA Attention Mechanism - Transformer attention written from scratch in CUDA attention.cu · Q·Kᵀ·scale → row-softmax → P·V · N=512, d=64 · baseline vs optimized, validated against a CPU reference

I hand-wrote and optimized the same primitives the YOLO models run millions of times per image:

Tiled shared-memory matmul32×32 tiles = warp size Kernel fusion (QKᵀ + scale)Virtual transpose Zero-pad boundary guardParallel softmax
ImplementationTime (N=512)Speed-up
CPU reference36.465 ms
Basic GPU (4 kernels)0.612 ms≈60×
Optimized GPU (tiled + fused)0.170 ms≈214× vs CPU
// attention.cu - the optimized fused QKᵀ kernel
#define TILE 32                          // = NVIDIA warp size → coalesced access
__global__ void qkt_fused_opt(const float* Q, const float* K, float* S, float scale){
  __shared__ float Qs[TILE][TILE], Ks[TILE][TILE];
  Ks[ty][tx] = (col < N && kK < D) ? K[col * D + kK] : 0.0f;  // virtual transpose + guard
  for (int k = 0; k < TILE; ++k) acc += Qs[ty][k] * Ks[k][tx];
  if (row < N && col < N) S[row * N + col] = acc * scale;   // KERNEL FUSION
}

Impact on CalEyeZ: a YOLO layer is this kernel at scale. Having built and profiled the tiled/fused matmul myself, the amp=True training run and GPU forward passes are not a black box, they are the production version of the kernel I wrote.

Foundation: Computer Architecture (3602801)

The GPU work is legible only because of the Von-Neumann model and the register-vs-memory latency hierarchy (registers ≈ 1 cyc, shared ≈ 5, global ≈ 500) that is the whole reason for tiling into shared memory.
My Computer Architecture project: a CPU. A single-cycle MIPS processor in Verilog, 32 registers, an ALU, full Fetch → Decode → Execute, then optimized across four revisions:
CPU revisionCyclesTechnique
Basic single-cycle MIPS53standard fetch/decode/execute loop
Extended ISA6custom ALU op (func 101011) = n(n+1)/2 − m(m−1)/2 in hardware
Minimal 2-cycle2stripped datapath, dedicated accumulator
Pure combinational0clockless logic, result instant

Impact: designing a datapath and extending an instruction set is the same instinct behind choosing FP16 tensor paths and fused CUDA kernels above. A GPU is this machine, massively parallelised.

Code: scripts/training/train_general_model.py, train_israeli_food_v2.py, scripts/demo/caleyez_demo.py.
4

Practical Machine Learning · למידת מכונה יישומית

Course 3644118 · supervised & deep learning, image recognition, evaluation

What it put into CalEyeZ

Two supervised deep-learning classifiers (132-class + 13-class), trained with honest splits, SHA-256 de-duplication (zero leakage), confusion matrices, and calibrated confidence.

The CalEyeZ code

# scripts/training/train_israeli_food_v2.py - supervised deep learning + calibration
model = YOLO("yolo11l-cls.pt")
results = model.train(
    data=DATASET_DIR, task="classify",
    epochs=120, patience=25,        # early-stop on val top-1 = no overfitting
    label_smoothing=0.1,           # calibrated, trustworthy probabilities
)

My course project → the methodology it taught

Predictive Data-Science Pipeline for USA Housing Prices realtor-dataset-100k.csv · 100k rows · pandas / scikit-learn / XGBoost

Hierarchical median imputationIQR outlier trimming Feature engineeringLinear · Ridge · RF · XGBoost
StageBest model
Raw featuresLinear regression0.33
After feature engineeringLinear / Ridge0.77
Non-linearRandom Forest0.98

Impact on CalEyeZ: identical discipline, leakage-free splits and metric-driven decisions, applied to images instead of tables. It also taught me when feature engineering beats a fancier model, which directly shaped the hand-built 20-feature arbiter vector.

Code: scripts/training/train_israeli_food_v2.py, train_general_model.py · Results: runs/*/confusion_matrix.png.
5

Contents & Applications in Artificial Intelligence · יישומים בבינה מלאכותית

Course 3604142 · decision trees / random forest / KNN, clustering, cosine similarity

What it put into CalEyeZ

The XGBoost arbiter is a gradient-boosted decision-tree ensemble (the tree/forest family this course teaches), engineered as a model: 20-feature vector, scale_pos_weight for imbalance, ROC-AUC, and a baseline-vs-oracle trade-off study.

The CalEyeZ code

# scripts/arbiter/train_arbiter_xgb.py - a boosted decision-tree ensemble + honest eval
clf = xgb.XGBClassifier(n_estimators=400, max_depth=5, learning_rate=0.05,
    scale_pos_weight=spw, eval_metric="auc")        # class imbalance handling
auc        = roc_auc_score(yte, proba)            # characterise the model
routed_acc = routed_correct.mean()                # vs an always-global baseline…
oracle_acc = (g_corr | i_corr).mean()             # …and the perfect-router ceiling

My course project → the arbiter's algorithm family

Decision-tree & ensemble modelling (from the Housing-Price pipeline) inspected the split matrix: root acre_lot ≤ 293.46, children on house_size, bath

Impact on CalEyeZ: reading decision-tree splits is exactly how the arbiter routes, a learned hierarchy of threshold splits on the 20 features. The XGBoost choice is a deliberate one I understand from the inside, not a black box.

Code: scripts/arbiter/train_arbiter_xgb.py, generate_arbiter_dataset.py.
6

Sensors and Actuators · חיישנים ומפעלים

Course 3603820 · prereq: Analog Electronics 1&2 · sensitivity vs linearity, accuracy vs sampling rate

What it put into CalEyeZ

The SWAN BLE scale is a load-cell (strain-gauge) sensor. I reverse-engineered its wire protocol (8-byte packet, little-endian grams, carry bit) and implemented the accuracy-vs-sampling-rate trade-off with a median filter + a stability gate.

The CalEyeZ code

# scripts/ble/scale_reader.py - raw transducer bytes → grams, with a stability spec
low_byte  = hex_data[4]
high_byte = hex_data[5] | hex_data[3]          # full high byte = number of 256s (no mask)
weight = sorted(raw_buffer)[len(raw_buffer)//2]      # MEDIAN filter = noise vs responsiveness
delta  = max(stability_buffer) - min(stability_buffer)
software_stable = delta <= STABILITY_THRESHOLD        # ≤2 g over 10 samples ⇒ STABLE

My course project → the same trade-offs in hardware

Portable Metal-Detector Circuit LC-tank oscillator + BJT signal chain · LTspice + Excel model · ≈47 h battery

f = 1/(2π√(L·C))Sensitivity sweep (−20,000 kHz/H) Linearity region 10-30 mHModel vs LTspice <15%

Impact on CalEyeZ: this is where sensitivity vs linearity and accuracy vs sampling rate became physical realities of a transducer, the exact trade-offs the BLE scale's median filter + stability gate implement above.

Analog foundation: Analog Electronics & VLSI

CMOS Op-Amp - 69 dB · 4.075 MHz · 67° PM · 113 dB CMRR CMOS Ring Oscillator - 5-stage, 413 MHz, temp/VDD swept

A strain-gauge load cell outputs microvolts; recovering it needs the differential amplification and offset behaviour I designed in the op-amp project, and the scale + BLE radio run on the kind of on-chip oscillator I characterised. These are the analog physics that make "read grams over Bluetooth" possible.

Code: scripts/ble/scale_reader.py; same protocol in scripts/demo/caleyez_demo.py.
7

Signal & Image Processing · עיבוד אות ותמונה

Course 3603834 · 1-D signals + 2-D images, filtering, pattern recognition

What it put into CalEyeZ

A 2-D image front-end (center-ROI crop, gray-world white balance, CLAHE) and 1-D filtering on the sensor stream. The course teaches that 1-D signals and 2-D images are the same maths in different dimensions, and CalEyeZ uses both.

The CalEyeZ code

# scripts/demo/caleyez_demo.py - 2-D image filtering before recognition
def center_roi(bgr):
    h, w = bgr.shape[:2]
    s = int(min(h, w) * ROI_FRAC)        # deterministic 2-D crop
    y0, x0 = (h - s)//2, (w - s)//2
    return bgr[y0:y0+s, x0:x0+s].copy()
# scripts/ble/scale_reader.py - the SAME course's 1-D filtering, on the sensor signal
weight = sorted(raw_buffer)[len(raw_buffer)//2]   # median = a non-linear 1-D digital filter

Immediate impact

The same filtering instinct cleans the camera image (2-D) and the scale's weight stream (1-D). The convolution the GPU course accelerates is the very operation this course defines.
Code: scripts/demo/caleyez_demo.py (image front-end), scripts/ble/scale_reader.py (1-D filter).

Recap: course → subsystem → proof

#CourseSubsystem it becameProof in the repo
1Python for E&EThe whole hardware-driving programcaleyez_demo.py
2Software / C (OOP)Classes, buffers, algorithms, bounds-checkscaleyez_demo.py
3GPU Accelerator (CUDA)The GPU that runs the models (FP16, convolutions)train_general_model.py
4Practical MLTwo supervised deep-learning classifierstrain_israeli_food_v2.py
5Applications in AIThe XGBoost decision-tree arbiter / routerarbiter/train_arbiter_xgb.py
6Sensors & ActuatorsThe BLE load-cell scale + stability specble/scale_reader.py
7Signal & Image Processing1-D weight filter + 2-D image normalisationcaleyez_demo.py · scale_reader.py
+Computer ArchitectureVon-Neumann/FDE model behind the GPU + a MIPS CPU in Verilogfolded into ③ · MIPS project
The conclusion. Remove any one course and a real part of CalEyeZ vanishes: no sensor, no signal conditioning, no GPU acceleration, no classifier, no router, no scaffolding. The software is the nervous system, but the project itself is electronics: transducers, analog front-ends, parallel silicon, and the floating-point math that runs on it. The camera is the easy part to see; the engineering is everything that turns its photons and the load cell's microvolts into a trustworthy number.
CalEyeZ · Electronic Engineering · how each course and its project became a working subsystem.

Per-Class Performance

The 86.16% system figure is an average. This view breaks it down to every one of the 145 classes: its recall on the held-out test set, the food it is most confused with, and why. This is where the real limitations live.

Reading it: recall = the share of that class's test images identified correctly, so 75% recall means 25% were missed. But recall on a tiny test set is noisy: a class with only 8 images moves 12.5% per single miss, so those numbers carry a wide 95% confidence interval and should not be ranked literally. Use reliable only (n ≥ 50) to see the weaknesses that are statistically real.

-
Classes
-
Mean recall
-
≥ 90% recall
-
< 75% recall
Don't be fooled by the bottom of the list. The apparent "worst" classes (apple 37.5%, chili pepper 50%) have only 7-10 test images, so their recall is a high-variance estimate, not a real failure - apple's 3/8 gives a 95% interval of roughly 14-69%, and it performs fine in real use. The fix for those is a bigger test set, not more training data.
The real, reliable weaknesses (n ≥ 100, so the number is trustworthy) are: chocolate mousse 57%, falafel 70%, foie gras 71%, apple pie 71%, ravioli 72%, hummus 74%. Some are genuine visual twins (ravioli↔gnocchi, chocolate mousse↔chocolate cake); others are simply under-trained. This is where more data actually moves the needle - and it is a model/data limit, not a routing fault.
ClassRecalln (test)Most confused withWhy
CalEyeZ · per-class recall on the held-out test set (145 classes, routed system).

The Voice-Fingerprint Analogy

CalEyeZ is a convolutional neural network, but you can understand exactly what it does without any deep-learning vocabulary - just signals and filters. Imagine building a system that recognises who is speaking. That system and CalEyeZ are the same machine; only the input changes from a voiceprint to a food photo.

A voice enters on the left, passes the learned filter bank, becomes a feature vector ("voice DNA"), and is scored into probabilities by softmax.
speaker Learned filter bank (backbone) band-pass → pitch f0 band-pass → timbre band-pass → cadence band-pass → loudness band-pass → formants Voice DNA (vector v) ·template + bias → logit → softmax → P Alice z=4.1 0.71 Bob z=2.0 0.17 Carol z=1.1 0.08 Dan z=0.3 0.04 winner = highest probability → "this is Alice"

The story, step by step

1

Collect many voices, all speaking the same language

We record many people speaking, say, English. Each raw recording is just a pressure wave - far too much data to compare directly. We need to boil every voice down to a few numbers that capture what makes it that voice.

2

Pass each voice through a bank of filters

Classic signal processing gives us band-pass filters (IIR, Butterworth, …). Each one isolates one aspect of the sound: the pitch (fundamental frequency f0), the timbre, the speaking cadence, the loudness, the vowel formants. The difference here is that these filters are not hand-designed with fixed coefficients - they start random and are learned from the data. In a CNN these "filters" are the convolution kernels, and the stack of them is called the backbone.

3

Training tunes the filters - but only the final answer is graded

This is the one place the intuition needs care. We do not tell the network the "true pitch" or "true timbre" of each voice - there is no measured target for the intermediate features. The only thing we grade is the final guess: did it name the right speaker? We measure how wrong that guess was (the loss) and backpropagation pushes that single error backwards through every filter, nudging each one a little at every epoch. Over many epochs the filters self-organise to extract whatever features make the final answer correct. They discover "pitch and timbre matter" on their own - nobody hands them that.

4

The output is a vector - the voice's "DNA"

After the filter bank, each voice is reduced to a short list of numbers, v = {pitch, timbre, cadence, loudness, …}. This vector is the fingerprint of that voice. In CNN terms it is the embedding (or feature vector).

5

Recognition: dot-product against every learned template

To recognise a new voice, we run it through the very same filters to get its vector v. The final layer stores one learned template (a weight vector) for every speaker it knows. We take the dot product of v with each template and add a bias: z_c = w_c · v + b_c. The bigger the dot product, the more the input lines up with that speaker's template. Those raw scores z (e.g. 4.1, 2.0, 1.1, 0.3) are the logits.

6

Softmax turns scores into probabilities

Logits are unbounded numbers, not probabilities. Softmax exponentiates each one and normalises so they sum to 1:

p_i = e^{z_i} / Σ_j e^{z_j}   →   {0.71, 0.17, 0.08, 0.04}  (sums to 1)

The largest probability is the system's answer. That is the entire recognition pipeline.

Adding a second accent - the catastrophic-forgetting problem

Now suppose we want the same system to also recognise Israeli speakers. They use the same physics of speech, but the statistics differ - louder on average, different cadence and formants. We have three options:

OptionWhat you changeResult
1 · Retrain all filters Re-tune the whole backbone on Israeli voices. The filters drift to fit the new voices and forget the English ones - catastrophic forgetting. We measured exactly this and rejected it.
2 · Fine-tune only the head Freeze the filters; only re-weight the features (e.g. trust cadence ×10, loudness ×0.5). Standard transfer-learning fix. Cheap, keeps old knowledge, but limited if the new domain really needs different features.
3 · Two specialists + a router Keep the English expert untouched, train a separate Israeli specialist, and add a learned router that decides per input which expert to trust. What CalEyeZ does. No forgetting, and each expert stays sharp on its own domain.

CalEyeZ took option 3. The Israeli specialist even carries an extra "background / not-mine" class, so when an English voice reaches it, it can say "this isn't mine" - which is the strongest signal the router uses to send the input back to the English expert.

Stage 2 - the router in action. Both experts always run. The router reads their confidence, uncertainty and the "not-mine" signal, then decides which answer to trust. (Uses the same ▶ Play above.)
hummus photo → feature vector v Global expert · 132 classes top: mashed_potato .34 entropy high → model is unsure Israeli expert · 13 dishes + background top: hummus .91 P(background) = 0.04 → "this one IS mine" XGBoost router reads 20 features P(israeli)=0.88 ≥ 0.5 → route Israeli hummus routed to Israeli expert

The reverse case is just as important: feed an English food and the Israeli expert returns a high P(background) ("not mine") with low confidence, so the router sends the decision to the Global expert instead. That single "not-mine" signal is the top feature in the real arbiter and is why routing reaches ROC-AUC 0.973.

How this maps onto CalEyeZ - exactly

Voice analogyCalEyeZ (the real system)
Raw voice recordingRaw food photo (RGB image)
Band-pass filters (IIR/Butterworth)Convolution kernels (learned filters)
The whole filter bankThe CNN backbone (YOLO11l-cls feature extractor)
Filters auto-tuned by the final errorBackpropagation of cross-entropy loss (with label smoothing ε=0.1)
Voice DNA vector vThe image embedding / feature vector
Dot product with each speaker templateFinal classification layer: z_c = w_c · v + b_c
Raw scores zLogits (one per food class)
Softmax → probabilitiesSoftmax → per-class confidence (132 global / 13+1 Israeli)
132 English speakers132 international food classes (Global model)
Israeli speakers + "not-mine" classIsraeli specialist: 13 dishes + open-set "background" class
Catastrophic forgetting when retraining all filtersExactly why we did not extend the Global model - measured and rejected
Router choosing which specialist to trustXGBoost arbiter (20 features, ROC-AUC 0.973)
Precision note (so the analogy is honest)

The analogy is exact except for one subtlety worth stating: in pure DSP you could measure pitch directly and tune a filter against that measured value. A CNN cannot - there is no ground truth for any intermediate feature. The network is graded only on the final class, and the filters are shaped indirectly by backpropagating that final error. So "we fine-tune the filters" is true, but the teacher is the answer at the end, never the features in the middle.

Want to see the softmax math live (drag the logits and watch the probabilities, entropy and margin update)? Open the Training Lab. Want the routing decision explained per image? See the Arbiter tab.

CalEyeZ · the same machine that recognises a voice recognises a plate of food.