Skip to content

Commit 78055f4

Browse files
Merge pull request #148 from GAchuzia/feature/confidence-limits
feat: improve food scanning UX for low-confidence and non-food images
2 parents 0fc076b + 2cde749 commit 78055f4

4 files changed

Lines changed: 91 additions & 6 deletions

File tree

backend/endpoints/ml_endpoints.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ def predict_food_image():
3535
- Body: image file (form field name: 'image')
3636
3737
Responses:
38-
200 OK - Successfully predicted food
39-
Response Body (JSON):
38+
200 OK - Prediction result (either success or low confidence)
39+
If confidence >=80%:
4040
{
41+
"success": true,
4142
"food_name": string,
4243
"confidence": float,
4344
"calories": int
@@ -60,6 +61,13 @@ def predict_food_image():
6061
"has_treenuts": bool,
6162
"has_wheat": bool,
6263
}
64+
If confidence < 80% (unclear or non-food image):
65+
{
66+
"success": false,
67+
"reason": "low_confidence",
68+
"confidence": float,
69+
"message": string,
70+
}
6371
6472
400 Bad Request - No file provided or invalid file
6573
500 Internal Server Error - Prediction failed

backend/services/ml_service.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
MODEL_PATH = MODEL_DIR / "best_model.pth"
2525
CLASS_NAMES_PATH = MODEL_DIR / "class_names.json"
2626

27+
# Minimum confidence to accept a prediction (handles both unclear photos and non-food images).
28+
MIN_CONFIDENCE_THRESHOLD = 0.80
29+
2730
# Global variables to cache the model
2831
_model = None
2932
_class_names = None
@@ -115,6 +118,16 @@ def predict_food(image_file):
115118

116119
predicted_class = class_names[predicted_idx.item()]
117120
confidence_score = confidence.item()
121+
confidence_pct = round(confidence_score * 100, 2)
122+
123+
# Reject low-confidence predictions: unclear photo or non-food (model is food-only).
124+
if confidence_score < MIN_CONFIDENCE_THRESHOLD:
125+
return {
126+
"success": False,
127+
"reason": "low_confidence",
128+
"confidence": confidence_pct,
129+
"message": "We couldn't identify the food with enough confidence. Please retake a clear photo of your food.",
130+
}
118131

119132
# Extract food name (remove dataset prefix like "food101:")
120133
food_name = predicted_class.split(":")[-1].replace("_", " ").title().strip()
@@ -125,8 +138,9 @@ def predict_food(image_file):
125138

126139
# Return unknowns for everything besides the name
127140
return {
141+
"success": True,
128142
"food_name": food_name,
129-
"confidence": round(confidence_score * 100, 2),
143+
"confidence": confidence_pct,
130144
"calories": -1,
131145
"fat_g": -1,
132146
"carbs_g": -1,
@@ -152,8 +166,9 @@ def predict_food(image_file):
152166

153167
# Fill in the object attributes based on the generic category
154168
obj = {
169+
"success": True,
155170
"food_name": food_name,
156-
"confidence": round(confidence_score * 100, 2),
171+
"confidence": confidence_pct,
157172
"calories": food_category.calories,
158173
"fat_g": food_category.fat_g,
159174
"carbs_g": food_category.carbs_g,

frontend/cu-bytes/app/_styles/style-scan.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ export const styles = StyleSheet.create({
2323
width: w(100),
2424
},
2525

26+
lowConfidenceBanner: {
27+
alignItems: 'center',
28+
backgroundColor: '#AB0006',
29+
borderColor: '#FFFFFF',
30+
borderWidth: 2,
31+
borderRadius: 12,
32+
marginTop: h(1.5),
33+
marginBottom: h(1.0),
34+
paddingVertical: h(2.0),
35+
paddingHorizontal: w(4.0),
36+
width: w(80),
37+
},
38+
39+
lowConfidenceTitle: {
40+
color: '#FFFFFF',
41+
fontFamily: 'arial',
42+
fontSize: font(175),
43+
fontWeight: '700',
44+
marginBottom: h(0.8),
45+
textAlign: 'center',
46+
},
47+
48+
lowConfidenceMessage: {
49+
color: '#FFFFFF',
50+
fontFamily: 'arial',
51+
fontSize: font(140),
52+
fontWeight: '600',
53+
textAlign: 'center',
54+
},
55+
2656
savedFoodItemMessageContainer: {
2757
alignItems: 'center',
2858
backgroundColor: '#AB0006',

frontend/cu-bytes/app/scan.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useUser } from './_context';
1010
import { apiService, API_BASE_URL } from '../services/api';
1111

1212
interface PredictionResult {
13+
success?: true;
1314
food_name: string;
1415
confidence: number;
1516
calories: number;
@@ -33,12 +34,21 @@ interface PredictionResult {
3334
has_wheat: boolean;
3435
}
3536

37+
/** Returned when confidence is below threshold (e.g. unclear photo or non-food). */
38+
interface LowConfidenceResponse {
39+
success: false;
40+
reason: 'low_confidence';
41+
confidence: number;
42+
message: string;
43+
}
44+
3645
export default function ScanScreen() {
3746

3847
const [loading, setLoading] = useState(false);
3948
const [modalVisible, setModalVisible] = useState(false);
4049
const [selectedImage, setSelectedImage] = useState<string | null>(null);
4150
const [prediction, setPrediction] = useState<PredictionResult | null>(null);
51+
const [lowConfidenceMessage, setLowConfidenceMessage] = useState<string | null>(null);
4252
const [isBackPressed, setIsBackPressed] = useState(false);
4353
const [isLoginLogoutPressed, setIsLoginLogoutPressed] = useState(false);
4454
const [isUploadPhotoPressed, setIsUploadPhotoPressed] = useState(false);
@@ -356,8 +366,9 @@ export default function ScanScreen() {
356366
if (!result.canceled && result.assets[0]) {
357367

358368
setSelectedImage(result.assets[0].uri);
359-
// Clear previous prediction
369+
// Clear previous prediction and low-confidence message
360370
setPrediction(null);
371+
setLowConfidenceMessage(null);
361372
}
362373
} catch (err) {
363374
console.error('Error picking image:', err);
@@ -387,6 +398,7 @@ export default function ScanScreen() {
387398
if (!result.canceled && result.assets[0]) {
388399
setSelectedImage(result.assets[0].uri);
389400
setPrediction(null);
401+
setLowConfidenceMessage(null);
390402
}
391403
} catch (err) {
392404
console.error('Error taking photo:', err);
@@ -409,7 +421,17 @@ export default function ScanScreen() {
409421
try {
410422
const result = await apiService.predictFood(selectedImage);
411423
console.log('Prediction result received:', result);
412-
setPrediction(result);
424+
425+
const lowConf = result as LowConfidenceResponse;
426+
if (lowConf.success === false && lowConf.reason === 'low_confidence') {
427+
setLowConfidenceMessage(lowConf.message);
428+
setPrediction(null);
429+
Alert.alert('Please retake the photo', lowConf.message, [{ text: 'OK' }]);
430+
return;
431+
}
432+
433+
setLowConfidenceMessage(null);
434+
setPrediction(result as PredictionResult);
413435
} catch (err: any) {
414436
console.error('Prediction error:', err);
415437
Alert.alert('Error', err.response?.data?.error || 'Failed to predict food. Please try again.');
@@ -531,6 +553,16 @@ export default function ScanScreen() {
531553
</Text>
532554
)}
533555

556+
{/* Display message when confidence was too low (e.g. unclear or non-food image) */}
557+
{lowConfidenceMessage && (
558+
<View id="lowConfidenceBanner" style={styles.lowConfidenceBanner}>
559+
<Text style={styles.lowConfidenceTitle}>No food detected</Text>
560+
<Text style={styles.lowConfidenceMessage}>
561+
We couldn&apos;t detect any food in this photo. Please take a clear photo of the food item.
562+
</Text>
563+
</View>
564+
)}
565+
534566
{/* Display information about the selected food item */}
535567
{prediction && (
536568
<View id="foodItemOuterView" style={styles.selectedFoodItemContainer}>

0 commit comments

Comments
 (0)