Ai-assisted Smart Food Waste Station

by maxime peetermans in Circuits > Raspberry Pi

22 Views, 0 Favorites, 0 Comments

Ai-assisted Smart Food Waste Station

ChatGPT Image 19 jun 2026, 09_20_25.png
fruit.png
Screenshot 2026-06-18 230842.png
fruit.png
coockie.png

What I made

A self-contained station that scans cafeteria trays after the meal. Customers walk up to the station after eating, place their tray under the camera, and scan their leftovers. A YOLO model identifies what is still on the tray, the result gets stored in the database, and each person receives a personal message on the LCD. A dashboard then shows kitchen managers what gets left over the most.

Why

Hot meals get overproduced in almost every large-scale kitchen. Food ends up in the bin every day, while people two streets away could have used it. Right now there is no scalable way to track what actually gets left on trays, so kitchen managers cannot fix what they cannot see. This project gives them eyes.

How

A Raspberry Pi 5 inside a laser-cut wooden enclosure runs everything locally. The YOLO26s model was trained from scratch on more than 2,500 annotations across 14 food classes: meat, chicken, fish and seafood, pasta, rice, pizza, potato, vegetables, apple sauce, soup, bread, bread with toppings, dessert, and fruit.

A rule-based layer turns the raw detections into structured data. A class-priority hierarchy decides the meal type from what was detected on the tray (meat meal, fish meal, soup meal, pasta meal, rice meal, potato meal, pizza meal, bread meal, dessert, fruit and combination of mealtypes also possible). On top of that, the system counts how many meal types are present and calculates a 2D surface scan per item, expressed as a portion fraction relative to a calibrated reference plate.

Results land in a PostgreSQL database through a FastAPI service. A Gradio dashboard reads back the aggregated data for kitchen managers. The whole system runs offline. No cloud.

Supplies

Screenshot 2026-06-18 223343.png
raspberri5.png
pushbutton.png
glue.png
nails.png
usb extension cable.png
smartplug.png
wooden screws.png
Polyfilla malleable wood.png
platter.png
connector plates.png
desktop light.png
proprylene sheet.png
multiplex.png
silicone sanitary.png

1. Raspberry Pi 5 — 4GB

Central microcontroller — runs AI inference, backend, and all hardware I/O

Qty: 1 | Units: 1 | Unit cost: €131,88 | Total: €131,88

Supplier: https://www.kiwi-electronics.com/nl/raspberry-pi-boards-363?ff1=32

Also available from: https://www.raspberrystore.nl/PrestaShop/nl/raspberry-pi-5/513-raspberry-pi-5-8gb-starter-pack-2023-8718734751687.html

2. Push Button Panel Mount 12mm

Momentary push button to trigger detection cycle (headless operation)

Qty: 1 | Units: 1 | Unit cost: €1,65 | Total: €1,65

Supplier: https://eu.robotshop.com

Also available from: https://thepihut.com/collections/buttons-switches

3. Multiplex 8mm 1600x450

Wooden panel (8mm) for laser-cut enclosure construction

Qty: 3 | Units: 3 | Unit cost: €5,80 | Total: €17,40

Supplier: https://mindandmakerspace.com/howest-studenten/

Also available from: Hubo — https://www.hubo.be/nl/

4. Multiplex 4mm 1600x450

Wooden panel (4mm) for laser-cut enclosure construction

Qty: 5 | Units: 5 | Unit cost: €3,90 | Total: €19,50

Supplier: https://mindandmakerspace.com/howest-studenten/

Also available from: Hubo — https://www.hubo.be/nl/

5. PP Sheet 0.5mm 1000x700mm

Polypropylene sheet for inner lining or protective cover

Qty: 1 | Units: 1 | Unit cost: €4,50 | Total: €4,50

Supplier: https://mindandmakerspace.com/howest-studenten/

Also available from: https://www.amazon.nl (search "polypropylene sheets")

6. Wood screw 3.5x20 T15 ZN (100st)

Wood screws for enclosure assembly

Qty: 50 | Units: 1 | Unit cost: €6,29 | Total: €6,29

Supplier: https://www.hubo.be/nl/

Also available from: https://www.brico.be/nl/

7. Wood screw 4x25 T20 ZN (50st)

Wood screws for enclosure assembly

Qty: 12 | Units: 1 | Unit cost: €5,69 | Total: €5,69

Supplier: https://www.hubo.be/nl/

Also available from: https://www.brico.be/nl/

8. Polyfilla malleable wood

Wood filler/putty for finishing enclosure joints and holes

Qty: 1 | Units: 1 | Unit cost: €6,99 | Total: €6,99

Supplier: https://www.hubo.be/nl/

Also available from: https://www.brico.be/nl/

9. Glue (Loctite Super Glue-3 Power Gel)

Putting the wooden box together first and check whether it's strong enough without nails

Qty: 1 | Units: 1 | Unit cost: €10,20 | Total: €10,20

Supplier: https://www.brico.be/nl/ijzerwaren/lijmen-tapes-reparatiekits/lijmen/secondelijm/loctite-secondelijm-super-glue-3-power-gel-mini-dose-3x1gr/10077144

Also available from: https://www.hubo.be/nl/p/loctite-super-glue-3-pure-gel-secondelijm-3g/327021/

10. Nails (Sencys round-head 2.0x40mm)

Nails for putting the wooden boxes together

Qty: 2 | Units: 2 | Unit cost: €2,40 | Total: €4,80

Supplier: https://www.brico.be/nl/ijzerwaren/technische-bevestigingsmaterialen/nagels-nieten-spijkers/ronde-kopspijkers/sencys-spijkers-met-ronde-kop-gehard-staal-2-0x40mm-50-stuks/5367950

Also available from: https://www.hubo.be/nl/p/mack-nagels-met-ronde-kop-1-2x20-mm-100g/208113/

11. Rubson Sanitary Silicone 280ml, Transparent

Transparent silicone sealant to seal openings and gaps in enclosure

Qty: 1 | Units: 1 | Unit cost: €9,69 | Total: €9,69

Supplier: https://www.hubo.be/nl/

Also available from: https://www.brico.be/nl/

12. Bagastro plates round (pack of 40)

Round white sugarcane plates for detection — using 15 of the 40-pack so plates aren't glass and can be carried

Qty: 15 | Units: 1 | Unit cost: €19,80 | Total: €19,80

Supplier: https://www.hanosshop.com/nl_nl/p/60216350/bagastro-bord-12-cm-40-stuks

Also available from: https://packagingdirect.com/bagastro-sugarcane-plate-round-white-240mm-40-pieces.html

13. Dienblad 450x350mm Polypropyleen Zwart

Black polypropylene tray (450x350mm) as detection surface

Qty: 4 | Units: 1 | Unit cost: €4,95 | Total: €4,95

Supplier: https://www.hanos.be/nl/Assortiment/Non-food/Bar-en-buffet/Barbenodigdheden/Dienbladen/DIENBLAD-450X350MM-POLYPROPYLEEN-ZWART/p/62101471

Also available from: https://www.123inkt.be (rechthoekig dienblad 30x40cm)

14. Connector plates / angle irons

For 3D-printing a mount for the camera

Qty: 10 | Units: 10 | Unit cost: €0,57 | Total: €5,70

Supplier: https://www.brico.be/nl/ijzerwaren/montage-bouwbeslag/hoekijzers-verbindingsplaten/hoekankers/alberts-verbindingshoek-blauw-verzinkt-staal-afgeronde-uiteinden-20x20x16mm/10022557

Also available from: https://www.hwt-pro.com/nl/meubelbeslag/meubelverbinders/korpusverbindingsstukken/6354/domax-platte-verbindingsplaten-van-staal-40-100-mm-set-van-4-stuks-57-x-13-x-2-0-mm

15. LED Light Panel (Rollei Lumis)

USB-powered light panel — daylight balanced (good for food photos)

Qty: 1 | Units: 1 | Unit cost: €69,99 | Total: €69,99

Supplier: https://www.mediamarkt.be (search "Rollei Lumis Key Light")

Also available from: https://www.rollei.com (LUMIS Key Light)

16. USB Extension Cable (1.0–2.0m)

Extends webcam or lamp cable if needed

Qty: 1 | Units: 1 | Unit cost: €8,99 | Total: €8,99

Supplier: https://www.mediamarkt.nl/nl/search.html?query=usb%20verlengkabel

Also available from: https://www.bol.com (search "ugreen usb 3.0 extension cable")

17. Logitech C920 Full HD Pro Webcam

1080p webcam for the L-arm of the wooden setup — autofocus, dual stereo mics, USB 2.0, 78° field of view

Qty: 1 | Units: 1 | Unit cost: €72,62 | Total: €72,62

Supplier: https://www.vandenborre.be (search "Logitech C920")

Also available from: https://www.amazon.com.be (Logitech C920 HD Pro)

18. Wi-Fi Smart Plug (Xiaomi Smart Plug 2 EU)

Smart plug for programmatic on/off lamp control — for bad lighting environment if needed

Qty: 1 | Units: 1 | Unit cost: €16,99 | Total: €16,99

Supplier: https://www.mediamarkt.be (search "smartplug xiaomi")

Also available from: https://www.ldlc.com (Xiaomi Smart Plug 2 EU)

Total parts: 18

Total cost: €417,63



Software (everything is free)

  1. Raspberry Pi Imager (to flash the SD card)
  2. Python 3.11 (comes with Raspberry Pi OS Bookworm)
  3. Docker + Docker Compose (apt install)
  4. Ultralytics YOLO (pip)
  5. Roboflow (free tier, for dataset management and annotation)
  6. VS Code with the Remote-SSH extension (your editor on the Pi over the network)

Plan the System

Screenshot 2026-06-18 225447.png

Before any wires get plugged in, it helps to know what is going to talk to what. The diagram below shows everything: hardware on the left, the two software runtimes on the Pi in the middle, and the people using the system on the right.

Two things might look unusual:

First, the database and API live in Docker containers, but the YOLO inference and the Gradio dashboard run natively in Python on the Pi. Why? Because Docker on a Pi cannot easily access USB cameras and GPIO pins without painful permission gymnastics. Splitting the system cleanly along that boundary made everything easier.

Second the RFID reader is only used in the developer Debug tab. It is not part of the regular scan flow. If you skip the RFID entirely, the kitchen workflow still works perfectly.


Why a rule-based layer on top of YOLO?

YOLO gives me a flat list of detections like "meat 0.91, vegetables 0.84, rice 0.62". That is not yet a decision. A kitchen manager wants to know whether this tray counts as having a meaningful leftover, what the meal type was, and how much of the original portion came back.

The rule-based layer turns the flat detections into that answer. It applies a class-priority hierarchy (soup > meat > chicken > fish > pizza > pasta > rice > potato > bread > dessert > fruit) to infer the meal type, calculates a portion fraction from the bounding-box area relative to a calibrated reference plate, and decides whether the tray crosses a leftover threshold.

The hierarchy itself was discussed and validated with my teacher. It is not magic, it is a set of opinionated business rules. That keeps it auditable: any manager can read the rule file and challenge it.

Design and Laser-Cut the Enclosure

fruit.png
coockie.png
Screenshot 2026-06-18 230842.png
Screenshot 2026-06-18 230910.png

The enclosure is a rectangular box with an L-shaped arm rising from one of the long sides. The camera sits at the end of the arm, looking straight down at where the tray will rest on the top surface.


My final box was 580 mm wide × 430 mm deep × 125 mm high. The arm adds another 280 mm of vertical clearance above the box surface, which gives the camera enough distance to fit a full Belgian school tray (53 × 37 cm) in frame at 1280×720.

Design considerations that mattered

  1. Slot-and-tab joints on all corners. The multiplex panels lock together without glue during dry assembly theoretically. To make sure i usded glue and also screws/nails wherre needed. not in the the side that i still should be able to open.
  2. Cable cutouts at the back: one for USB-C power, one for the camera USB cable that runs from inside the box up the arm to the camera.
  3. The camera arm has a small platform at the top, sized to fit the C920's mounting clip. The clip itself is what holds the camera, no glue needed.
  4. Slots on the front face for the LCD bezel, two buttons, the RFID reader window, and the NeoPixel mounting hole. Everything front-accessible.


Setup Raspberri Pi

Skip this step if your Pi is already running and you can SSH into it. For everyone else, here is the minimal setup.


Flash the SD card

  1. Download Raspberry Pi Imager from raspberrypi.com.
  2. Insert your microSD into your laptop.
  3. Pick Raspberry Pi OS (64-bit) Bookworm. Not Lite, you want the full image with all the drivers.
  4. Click the gear icon at the bottom right BEFORE flashing. Set a hostname, enable SSH with a public key, set your locale and timezone, and pre-configure Wi-Fi credentials. This saves you having to plug in a keyboard and monitor.
  5. Flash. This takes about 5 minutes on a USB 3 reader.


First boot and SSH in

Put the SD card in the Pi, plug in the official power supply, and wait about 30 seconds. From your laptop:



ssh student@your-pi-hostname.local

# or if mDNS does not work in your network:

ssh student@<the-pi-ip>


If SSH refuses with permission denied, double-check that you actually pasted your public key in the Imager settings. If mDNS does not resolve the hostname, find the Pi in your router's connected devices list to grab its IP.


Update and install the basics

sudo apt update && sudo apt full-upgrade -y

sudo apt install -y python3-pip python3-venv git i2c-tools v4l-utils


# Docker + Compose plugin

curl -fsSL https://get.docker.com -o get-docker.sh

sudo sh get-docker.sh

sudo usermod -aG docker $USER

# log out and back in so the group change takes effect


Enable I²C and SPI

Both interfaces are off by default. You need both:

sudo raspi-config nonint do_i2c 0

sudo raspi-config nonint do_spi 0

sudo reboot


# After reboot, verify:

ls /dev/i2c* /dev/spi*

# Expect /dev/i2c-1 and /dev/spidev0.0 + /dev/spidev0.1


Don't forget the UART hijack on GPIO 15

Raspberry Pi OS sometimes leaves the UART enabled by default, which silently steals GPIO 14 and 15 for serial console. My scan button sits on GPIO 15, so the first time I wired it up nothing worked.

Fix: sudo raspi-config → Interface Options → Serial Port → "login shell over serial?" NO → "serial hardware enabled?" NO. Reboot. Now GPIO 15 is yours.

Verify with: pinctrl get 15 — you want "GPIO15 = input", not "UART0_RXD".

Wire the Hardware

Screenshot 2026-06-18 232142.png

Wire each module in this order

Going module by module keeps the troubleshooting tight. If something breaks later, you know which wires you touched last.


LCD (I²C, 4 wires)

  1. VCC → Pi pin 4 (5V)
  2. GND → Pi pin 6
  3. SDA → Pi pin 3 (GPIO 2, I²C SDA)
  4. SCL → Pi pin 5 (GPIO 3, I²C SCL)


Power on the Pi briefly to test:

i2cdetect -y 1

# Expect a grid showing '27' (or '3F' on some modules) at the LCD's address


Push buttons (2 wires each)

Each button is a simple two-wire connection: one leg of the button to a GPIO pin, the other leg to any GND. Pull-up is handled by the internal Pi resistor in software, so you do not strictly need an external resistor.


  1. Scan button: one leg → Pi pin 10 (GPIO 15), other leg → Pi pin 9 (GND)
  2. Shutdown button: one leg → Pi pin 16 (GPIO 23), other leg → Pi pin 14 (GND)


NeoPixel LED (3 wires)

  1. VCC (5V) → Pi pin 2
  2. GND → Pi pin 39
  3. DIN (data) → Pi pin 38 (GPIO 20)


RFID reader (7 wires)

3.3V only!

The MFRC522 runs on 3.3V. If you connect it to 5V, the module dies. Permanently. I almost did this. Please do not.


  1. 3.3V → Pi pin 17
  2. GND → Pi pin 25
  3. SDA / CS → Pi pin 24 (GPIO 8, SPI CE0)
  4. SCK → Pi pin 23 (GPIO 11, SPI SCLK)
  5. MOSI → Pi pin 19 (GPIO 10, SPI MOSI)
  6. MISO → Pi pin 21 (GPIO 9, SPI MISO)
  7. RST → Pi pin 22 (GPIO 25). The IRQ pin stays disconnected.


USB camera and smart plug

These two have no GPIO at all. Plug the Logitech webcam into any USB-A port on the Pi (use a USB 3 port for better bandwidth). Verify it shows up with:

v4l2-ctl --list-devices

# Expect: HD Pro Webcam C920 — /dev/video0


The Tapo P110 smart plug just goes into a normal wall socket. It pairs over Wi-Fi using the Tapo phone app the first time, after which the Pi can talk to it over the LAN via the PyP100 Python library.


Collect and Annotate the Dataset

This is the longest step in the whole project. There is no shortcut. The model you train is exactly as good as the data you feed it, and the data only exists if you take the photos and label them one by one.


What I shot

  1. Around 2,600 photographs of real cafeteria trays.
  2. Taken at the Howest student restaurant over several weeks, with permission from the kitchen.
  3. Some trays after eating (leftovers), some just-served (full portions, used to calibrate "100%" references for each class).
  4. All shot from the same top-down angle, at the same height, under the same warm LED panel light. Consistency matters more than fancy lighting.


The 14 classes

After two rounds of iteration with my teacher I settled on these:


Main proteins

meat, chicken, fish_and_seafood

Starches

pasta, rice, potato, pizza

Sides

vegetables, apple_sauce, soup

Carbs

bread, bread_with_toppings

Dessert

dessert, fruit

Total

14 classes


Annotation workflow

  1. Upload all images to Roboflow (free tier handles up to 10,000 images per project).
  2. Use the polygon or bounding-box tool. Bounding boxes were enough for me; polygons would be marginally more accurate but take three times as long.
  3. Label every food item with the right class. Be strict. If you cannot tell whether something is bread_with_toppings or just bread, pick one and stick with it for the whole dataset.
  4. Split into train / val / test with Roboflow's auto-split (70 / 20 / 10).
  5. Generate a dataset version, export it in YOLOv8 format, download the zip. Inside is a data.yaml file you will need for training.


Fix the data.yaml path

After Roboflow exports, the data.yaml inside the zip points to ../train/images and similar relative paths. Edit it so the paths read simply train/images, val/images, test/images. Otherwise YOLO cannot find the data when you start training. This caught me on my first run.



Train the YOLO Model

Training happens on your laptop, not on the Pi. The Pi only runs inference. Don't try to train on the Pi, the 8 GB of RAM will not be enough and the lack of CUDA means each epoch takes hours.


Set up a training environment on your laptop

On Windows with an NVIDIA GPU, install CUDA Toolkit 12.x and matching PyTorch. I avoided Conda completely and just used a Python venv:

# In your project folder on the laptop

python -m venv venv

.\venv\Scripts\activate

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121

pip install ultralytics opencv-python


# Quick GPU sanity check

python -c "import torch; print('CUDA:', torch.cuda.is_available(), torch.cuda.get_device_name(0))"


Train YOLO26-nano

I used the nano variant because it has to run on a Raspberry Pi. The small or medium variants are more accurate but too slow on a Pi 5 to feel responsive when the user presses the button.

# Place your downloaded Roboflow zip in ./dataset/, unzip it.

# data.yaml should live at ./dataset/data.yaml.


from ultralytics import YOLO


model = YOLO('yolov8n.pt') # or YOLO26-nano weights when available


model.train(

data='dataset/data.yaml',

epochs=80,

imgsz=640,

batch=16,

device=0, # GPU 0

project='runs/detect',

name='food_waste_v3',

patience=15, # early stopping if val loss plateaus

)


On my RTX 3060 Laptop (55W TDP) this took roughly 2 hours per full training run. Across all four versions I trained, total GPU time was about 8 hours. The Green Algorithms calculator estimated the carbon cost at around 230 gCO₂eq for that, which is roughly a 1.5 km car ride.


Evaluate

After training finishes, check the metrics in the runs/detect/food_waste_v3/ folder. The most useful ones:

  1. results.png shows the loss curves. They should be flattening at the end.
  2. confusion_matrix.png shows where the model gets confused between classes. Mine had meat/chicken mixing slightly.
  3. val_batch0_pred.jpg shows actual predictions vs ground truth on validation images.


My v4 final model hit mAP@0.5 = 0.826, which I think is reasonable for more then 2500 annoations and 14 classes.


Copy the trained weights to the Pi

# Best weights live here after training:

# runs/detect/food_waste_v3/weights/best.pt


# From your laptop, copy to the Pi:

scp runs/detect/food_waste_v3/weights/best.pt student@your-pi.local:~/2025-26-projectone-ctai-PeetermansMaxime/RPi/runtime/models/

Build the Docker Backend

The backend is small and predictable: PostgreSQL for storage, FastAPI as a thin HTTP layer over it, and Adminer for poking at the DB during development.


Everything is defined in a single compose.yaml. The native runtime running on the Pi talks to it over localhost:8000. Nothing exotic.


compose.yaml

RPi/api/compose.yaml

services:

db:

image: postgres:18-alpine

environment:

POSTGRES_USER: foodwaste

POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

POSTGRES_DB: foodwaste

volumes:

- pgdata:/var/lib/postgresql/data

ports:

- "5432:5432"

healthcheck:

test: ["CMD-SHELL", "pg_isready -U foodwaste"]

interval: 5s


api:

build: ./api

depends_on:

db:

condition: service_healthy

environment:

DATABASE_URL: postgresql+psycopg://foodwaste:${POSTGRES_PASSWORD}@db:5432/foodwaste

ports:

- "8000:8000"


adminer:

image: adminer:5

ports:

- "8080:8080"


volumes:

pgdata:


Start it up

cd RPi/api


# Create a .env file with the DB password (anything you like)

echo 'POSTGRES_PASSWORD=changeme123' > .env


docker compose up -d

docker compose ps

# All three services should show 'Up' / 'healthy'


Open a browser and check:

  1. http://your-pi-ip:8000/docs — Swagger UI showing all FastAPI routes
  2. http://your-pi-ip:8080 — Adminer login. Use server: db, user: foodwaste, password: whatever you set


The five tables

The data model has five tables, finalised with my teacher:

  1. food_class — the 14 classes the model can detect
  2. meal_type — derived meal types (meat_meal, fish_meal, pasta_meal, etc.)
  3. food_class_meal_type — junction table linking classes to meal types they belong to
  4. tray_scan — one row per scan, with timestamp and metadata
  5. detection — one row per food item detected, linked to tray_scan


The schema is defined once in init_db.py using SQLAlchemy 2.0, loaded at startup via FastAPI's lifespan context manager. That keeps schema changes auditable: there is one place to look.



· Write the Rule-Based Meal Classifier

This module sits between YOLO and the API. It is a pure function: detections in, structured result out. No database access, no side effects. That makes it easy to test and easy for the dashboard to reuse on aggregated data.


The class hierarchy

To decide what kind of meal a tray represents when multiple things are detected, I use a strict priority order:


# Highest priority first. Soup is special and can coexist with mains.

CLASS_PRIORITY = [

'soup',

'meat',

'chicken',

'fish_and_seafood',

'pizza',

'pasta',

'rice',

'potato',

'bread_with_toppings',

'bread',

'dessert',

'fruit',

]


NON_WASTE_CLASSES = {'bread', 'bread_with_toppings', 'dessert', 'fruit'}

# These are never counted as 'leftovers' because the kitchen never planned to redistribute them.


Portion fraction calculation

YOLO gives me a bounding box in pixels. I convert that to a fraction of a reference plate area (530 cm² for a standard 26 cm plate). Each class has a typical-portion fraction so I can express "there is still about 40% of the rice left" as a number.


STANDARD_PLATE_AREA_CM2 = 530.0


TYPICAL_PORTIONS = {

'meat': 0.25, 'chicken': 0.25, 'fish_and_seafood': 0.25,

'vegetables': 0.25, 'apple_sauce': 0.15,

'rice': 0.50, 'pasta': 0.50, 'potato': 0.50,

'pizza': 1.00, 'soup': 0.60,

# bread, dessert, fruit use absolute cm², not fractions

}


SIGNIFICANCE_THRESHOLD is not been setter: it is been choose by myselfes(the labeler): when there is no significant leftover , nothing is been labeled

Build the Gradio Dashboard

The dashboard is the manager's window into the system. Gradio was the right tool for two reasons: it can render charts, tables, and live camera feeds in one Python file, and it produces a real web UI accessible from any device on the network.


Four tabs

  1. Operating — live camera preview, big SCAN button, last detection result overlayed on the image. This is what kitchen staff would see if they used the screen interface instead of the physical button.
  2. Data — table of all recent scans, breakdown charts by meal type and by day, average leftover percentages per class.
  3. Calibration — used once after the camera is mounted. You place a reference plate in frame, the system measures it in pixels, and writes the conversion factor to calibration.json.
  4. Debug — RFID developer ID display, manual class confidence threshold adjustment, raw JSON of the last scan. Only meant for the person maintaining the station.


Run it natively (not in Docker)

Because Gradio talks to the camera and GPIO, it runs natively on the Pi, not in a container.

# In ~/2025-26-projectone-ctai-PeetermansMaxime

python -m venv .venv

source .venv/bin/activate

pip install -r RPi/runtime/requirements.txt


# Make sure Docker stack is up first (Step 8)

python -m RPi.runtime.app


# Open http://your-pi-ip:7860 in any browser on the same network


The first launch downloads YOLO weights if they are not yet in models/best.pt, then loads them into memory. After that, scans take roughly one second from button press to LCD display.




Assemble the Final Station

fruit.png

With every module tested in isolation, time to put everything inside the wooden box.

  1. Mount the Raspberry Pi 5 on a 3D-printed mount, then screw the mount into the inner back wall of the enclosure. Leave clearance for the USB-C power cable to plug in horizontally.
  2. Mount the LCD, two buttons, and RFID reader to the front panel from the inside. Hold them in place with a mix of silicone, rubber gaskets, or tape, depending on the component and how snug the laser-cut slot is.
  3. Mount the NeoPixel next to the LCD on the front panel. A single LED is enough for the three status colors.
  4. Mount the LED light panel on the vertical arm, with an extra small box at eye level just below the horizontal section. This positions the light to fall directly over the tray surface for even, shadow-free capture.
  5. Route the camera USB cable up through the inside of the arm to the camera at the top.
  6. Zip-tie cable bundles so nothing rattles when the box gets moved.
  7. Drop a tray on the top surface and eyeball the framing through the Gradio Operating tab. Adjust the camera angle if needed before finalizing the arm.
Optional automation: Tapo smart plug
The LED light panel can run on a regular wall switch, fully manual. If you prefer software control (turn the light on only during the scan, off the rest of the time), plug it into a TP-Link Tapo P110 and let the runtime toggle it via Wi-Fi using the PyP100 library. Both modes work. The smart plug is a nice-to-have, not a requirement.


Configure Auto-Start With Systemd

So far you have been starting things by hand. The whole point of this build is that someone plugs it in and walks away. Two systemd services handle that.


foodwaste-docker.service

/etc/systemd/system/foodwaste-docker.service

[Unit]

Description=Food Waste Station - Docker stack

After=docker.service network-online.target

Requires=docker.service


[Service]

Type=oneshot

RemainAfterExit=true

User=student

WorkingDirectory=/home/student/2025-26-projectone-ctai-PeetermansMaxime/RPi/api

ExecStart=/usr/bin/docker compose up -d

ExecStop=/usr/bin/docker compose down


[Install]

WantedBy=multi-user.target


foodwaste-gradio.service

/etc/systemd/system/foodwaste-gradio.service

[Unit]

Description=Food Waste Station - Native Gradio runtime

After=foodwaste-docker.service

Requires=foodwaste-docker.service


[Service]

Type=simple

User=student

SupplementaryGroups=gpio i2c spi video dialout input

WorkingDirectory=/home/student/2025-26-projectone-ctai-PeetermansMaxime

ExecStart=/home/student/.venv/bin/python -m RPi.runtime.app

Restart=on-failure

RestartSec=5

TimeoutStartSec=120


[Install]

WantedBy=multi-user.target


Activate them

sudo cp RPi/systemd/*.service /etc/systemd/system/

sudo systemctl daemon-reload

sudo systemctl enable foodwaste-docker.service

sudo systemctl enable foodwaste-gradio.service

sudo systemctl start foodwaste-docker.service

sudo systemctl start foodwaste-gradio.service


# Sanity check after a reboot:

sudo reboot

# wait a minute, then SSH back in and:

sudo systemctl status foodwaste-gradio.service


From now on, every time you cut power and plug it back in, the station comes up by itself. No SSH needed.



Calibrate and Run Your First Scan

Screenshot 2026-06-16 140722.png
Screenshot 2026-06-18 235930.png
Screenshot 2026-06-18 235945.png
Screenshot 2026-06-19 000047.png

One-time setup after the camera is mounted in its final position. Without this step, the portion fraction calculations will be wrong.


Calibration

  1. Open the Gradio Calibration tab in a browser.
  2. Place a clean white reference plate (the same diameter you will use in deployment) on the tray surface.
  3. Click "Capture reference". The system measures the plate diameter in pixels and writes a pixel-to-cm factor to calibration.json.
  4. Repeat with the plate at different positions (centre, left, right) to make sure the camera sees the whole tray surface uniformly.


First scan

  1. Put a real tray with food on the surface.
  2. Press the physical scan button.
  3. NeoPixel turns yellow. LCD shows "Scanning...". The Tapo plug switches on the LED panel. Camera captures one frame. Plug switches off.
  4. Within about a second: LCD shows the result ("Meal: meat_meal" / "Leftover: yes (rice)" / a motivational message).
  5. NeoPixel returns to green (idle).
  6. Open the Data tab in Gradio. The scan should appear in the recent-scans table.


If it does not work

If the camera does not capture: check v4l2-ctl --list-devices and confirm /dev/video0 still exists.

If the LCD stays blank: rerun i2cdetect -y 1 and verify the I²C address is what config.yaml expects (0x27 or 0x3F).

If the model gives weird detections: the camera may be at a different height than during training. Recalibrate.

If the scan button does nothing: check that UART is disabled (Step 4.4) and that GPIO 15 reads "input" with pinctrl get 15.


Next Steps and Things I Would Change

This is what I would do differently or add if I picked this back up.


Things I would actually fix

  1. Expand the dataset with non-Western dishes. Couscous, dumplings, noodles, injera. My 14 classes reflect a Belgian school cafeteria. A hospital with multicultural patients would need a much wider model.
  2. Add an external pull-up resistor to each button. The internal pull-up is software-only and dies the moment the Pi is in safe shutdown, which causes phantom presses during reboot.
  3. Replace the Tapo smart plug with a hardware relay on a GPIO. Removes the network dependency, makes the system fully offline, lowers the latency of the LED panel switching from 500 ms to 50 ms.
  4. Add an automatic motivational-message rotation in config.yaml so the same line does not show up every scan.


Things that would be nice but are more work

  1. Bilingual LCD (Dutch and English) that switches based on the RFID badge of the kitchen staff member.
  2. A second camera at floor level to catch trays that are still half-full as someone walks past, no button press needed.
  3. A REST endpoint that exports daily stats to a food bank's API so surplus food gets allocated automatically.
  4. Train a much larger YOLO variant on a desktop GPU and quantize it for the Pi using OpenVINO. Should drop inference time below 300 ms while keeping accuracy.


The honest version

If you build this and the dataset annotation phase is destroying your motivation, you are normal. It is the worst part. Just push through, even 500 well-labelled images get you to a workable v1. After that, every iteration is more fun than the previous one.



Downloadable Files


Everything you need to reproduce this project lives in the GitHub repo:


Repository: https://github.com/howest-mct/2025-26-projectone-ctai-PeetermansMaxime.git


Specifically:

  1. Docker compose + API (/RPi_app/api/) with database schema in init_db.py
  2. Native Python runtime (/RPi_app/runtime/) with all hardware modules and the Gradio app
  3. Systemd service files (/RPi_app/systemd/) for foodwaste-docker and foodwaste-gradio
  4. Trained YOLO weights (/models/best.pt) — about 6 MB, ready to deploy
  5. Dataset on Roboflow : https://universe.roboflow.com/maximes-project-one/smart-food-waste-detection/dataset/4


Licence

Released under CC BY-NC-SA 4.0. Use it, learn from it, build on it. Do not sell it. If you publish derivative work, credit me and share back under the same licence.


Thanks

To the Howest CTAI teachers and coaches who refused to let me get away with sloppy ERDs.


To the kitchen staff at the Howest student restaurant who let me photograph hundreds of trays without ever complaining.


And to Kaan, my sparring partner, who caught at least three architecture decisions I would have regretted.


— End —