Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements*.txt
- run: pip install -r requirements-dev.txt
- run: pytest

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm test
- run: npx tsc --noEmit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ instance/

# Node (in case any exist at root level)
node_modules/
frontend/.test-dist/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
108 changes: 102 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,108 @@
# AddiGuard

AddiGuard is a project designed to...maby later when tne project is finished
Point your phone at an ingredients label and AddiGuard tells you which food additives it found and how risky they are.

## Project Structure
The Expo app crops a photo to the on-screen ingredients frame and posts it to a Flask API. The API runs OCR on the image (EasyOCR), matches E-numbers, names and aliases against a database of additives, and returns a Red / Yellow / Green rating for curated additives or Unrated when risk information is missing, plus an overall rating for the label.

- **backend/**: Python Flask application
- **frontend/**: React Native (Expo) application
## Project structure

## Getting Started
- **backend/** – Flask API (`POST /api/scan`), SQLite via Flask-SQLAlchemy, EasyOCR + thefuzz for matching.
- **frontend/** – Expo (React Native) app with a camera screen and a results screen.

(Instructions to be added)
## Backend

Requires Python 3.12.

```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt # CPU-only torch; ~1 GB
python seed.py # (re)creates the SQLite DB with sample additives
python import_additives.py # upserts the bundled Open Food Facts taxonomy, offline
FLASK_DEBUG=1 python run.py # serves on http://0.0.0.0:5000
```

The OCR models (~100 MB) are downloaded on the first `/api/scan` request, so expect that call to be slow once.

The additive schema includes a unique, nullable `e_number` (for example `E250`, `E150a` or `E101(i)`) and a JSON list of lowercase aliases. `seed.py` destructively rebuilds the database, so run it to adopt the new schema on an existing development database; there is no migration system yet. Back up any data you need first. The importer can then be run repeatedly without duplicating additives. It preserves the five seeded additives' existing factors and does not assign risk factors to newly imported entries. The bundled snapshot imports 643 concrete E-number entries, of which 638 are Unrated after seeding. Generic taxonomy categories and wildcard codes are excluded.

The unmodified Open Food Facts snapshot and its source, checksum and licensing information are in `backend/data/`; importing and testing do not require network access. To use an updated raw taxonomy, run `python import_additives.py /path/to/additives.txt`. The importer invalidates the current process's alias index; restart other running API processes after import, particularly when updating existing aliases without adding rows.

Configuration is via environment variables:

| Variable | Default | Purpose |
| -------------- | ------------------------ | ---------------------------------------- |
| `DATABASE_URL` | `sqlite:///addiguard.db` | SQLAlchemy connection string |
| `FLASK_DEBUG` | unset | Set to `1` for the debugger and reloader |
| `PORT` | `5000` | Port for `python run.py` |

Uploads are capped at 10 MB and must decode as an image.

### Tests

```bash
pip install -r requirements-dev.txt
pytest
```

## Frontend

```bash
cd frontend
npm ci
npm test # pure crop geometry tests, no device required
npx tsc --noEmit
npx expo start
```

The app needs to reach the backend. By default it targets port 5000 on the machine that serves the Expo dev bundle, which works for a physical device on the same Wi‑Fi and for emulators. To point it elsewhere, create `frontend/.env`:

```
EXPO_PUBLIC_API_URL=http://192.168.1.20:5000/api
```

In development builds a **Mock Mode** switch on the camera screen returns canned results so the UI can be exercised without a backend.

The camera measures the preview and scan frame, maps the frame through the preview's centered aspect-ratio fill to captured photo pixels, and crops before upload. The cropped image is downscaled to at most 1600 pixels on its long side and encoded as JPEG at quality 0.8. Only the framed region is sent to OCR. Mock Mode bypasses image processing. Scan results live in a React context rather than URL parameters; opening the results route without an in-memory scan redirects to the camera.

## API

`POST /api/scan` with a `multipart/form-data` body containing an `image` file.

```json
{
"status": "success",
"additives_found": 1,
"overall_risk_score": 0.83,
"overall_traffic_light": "Red",
"results": [
{
"name": "Sodium Nitrite",
"matched_text": "e250",
"risk_score": 0.83,
"traffic_light": "Red",
"details": { "id": 1, "name": "Sodium Nitrite", "e_number": "E250", "aliases": ["sodium nitrite"], "toxicity_level": 9, "...": "..." }
}
]
}
```

Errors return `{ "error": "..." }` with a 4xx/5xx status.

Each result includes `matched_text`, the normalized OCR phrase or code that triggered detection. Names and aliases match on phrase boundaries, with conservative fuzzy matching for longer aliases to tolerate OCR mistakes; short aliases such as `MSG` only match exactly. Explicit E-number variants such as `E 250` and `E-250` are recognized, while bare numbers require an additive-class context such as `preservative (250)` or `flavour enhancer (621)`. Multiple hits for the same additive produce one result.

### Risk score

Each additive has four 1–10 factors. The score is a weighted sum normalised to 0–1:

```
risk = toxicity·0.4 + exposure·0.3 + sensitivity·0.2 + cumulative·0.1
```

`> 0.7` is Red, `0.4–0.7` is Yellow, `< 0.4` is Green. The overall label rating is the highest-scoring additive found. The backend is the only place these thresholds live; the app just renders the `traffic_light` values it receives.

If any risk factor is null, that additive has `risk_score: null` and `traffic_light: "Unrated"`. Unrated detections are shown with a grey information badge and excluded from the overall score; they are not a claim of safety. If every detection is Unrated, the overall score is null and the overall traffic light is Unrated. An empty result retains `overall_risk_score: 0` and `overall_traffic_light: "Green"` for compatibility; no detection is not proof of a safe label.

## Status

Early MVP. The seed data has five additives with placeholder risk factors and health notes; none of it is medical advice. Open Food Facts expands detection coverage, not the curated risk database. OCR and approximate matching can still miss ingredients or return false positives, and the preview-to-photo mapping still needs validation on physical iOS and Android devices.
10 changes: 0 additions & 10 deletions backend/app.py

This file was deleted.

32 changes: 21 additions & 11 deletions backend/app/__init__.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
from flask import Flask
import os

from flask import Flask, jsonify
from flask_cors import CORS
from werkzeug.exceptions import HTTPException

from app.models import db
from app.routes import scan_bp

def create_app():

def create_app(test_config=None):
app = Flask(__name__)

# Configuration
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///addiguard.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config.from_mapping(
SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///addiguard.db"),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
MAX_CONTENT_LENGTH=10 * 1024 * 1024,
)
if test_config:
app.config.update(test_config)

# Initialize extensions
db.init_app(app)

# Register Blueprints
app.register_blueprint(scan_bp, url_prefix='/api')
CORS(app, resources={r"/api/*": {"origins": "*"}})
app.register_blueprint(scan_bp, url_prefix="/api")

@app.errorhandler(HTTPException)
def handle_http_error(error):
return jsonify({"error": error.description}), error.code

# Create tables
with app.app_context():
db.create_all()

Expand Down
12 changes: 8 additions & 4 deletions backend/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ class Additive(db.Model):

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False)
e_number = db.Column(db.String(16), unique=True, nullable=True)
aliases = db.Column(db.JSON, nullable=False, default=list)
description = db.Column(db.Text, nullable=True)

# Risk factors (1-10 scale)
toxicity_level = db.Column(db.Integer, default=1) # toxicity_score
exposure_level = db.Column(db.Integer, default=1) # exposure_risk
sensitivity_level = db.Column(db.Integer, default=1) # sensitivity
cumulative_level = db.Column(db.Integer, default=1) # cumulative_effect
toxicity_level = db.Column(db.Integer, nullable=True, default=None) # toxicity_score
exposure_level = db.Column(db.Integer, nullable=True, default=None) # exposure_risk
sensitivity_level = db.Column(db.Integer, nullable=True, default=None) # sensitivity
cumulative_level = db.Column(db.Integer, nullable=True, default=None) # cumulative_effect

health_risk = db.Column(db.String(255), nullable=True) # Text description
usage_limit = db.Column(db.String(255), nullable=True)
Expand All @@ -26,6 +28,8 @@ def to_dict(self):
return {
'id': self.id,
'name': self.name,
'e_number': self.e_number,
'aliases': self.aliases or [],
'description': self.description,
'toxicity_level': self.toxicity_level,
'exposure_level': self.exposure_level,
Expand Down
46 changes: 27 additions & 19 deletions backend/app/routes.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,38 @@
from flask import Blueprint, request, jsonify
from io import BytesIO

from flask import Blueprint, current_app, jsonify, request
from PIL import Image

from app.services.scan_service import ScanService

scan_bp = Blueprint('scan', __name__)
scan_service = ScanService()


@scan_bp.route('/scan', methods=['POST'])
def scan_image():
if 'image' not in request.files:
file = request.files.get('image')
if file is None or file.filename == '':
return jsonify({"error": "No image provided"}), 400

file = request.files['image']
if file.filename == '':
return jsonify({"error": "No selected file"}), 400

image_bytes = file.read()
try:
Image.open(BytesIO(image_bytes)).verify()
except Exception:
return jsonify({"error": "Uploaded file is not a valid image"}), 400

try:
# Read image bytes directly
image_bytes = file.read()

# Analyze
results = scan_service.analyze_image(image_bytes)

return jsonify({
"status": "success",
"additives_found": len(results),
"results": results
})

except Exception as e:
return jsonify({"error": str(e)}), 500
except Exception:
current_app.logger.exception("Image analysis failed")
return jsonify({"error": "Image analysis failed"}), 500

rated_scores = [r["risk_score"] for r in results if r["risk_score"] is not None]
overall_risk_score = max(rated_scores) if rated_scores else (None if results else 0)
return jsonify({
"status": "success",
"additives_found": len(results),
"overall_risk_score": overall_risk_score,
"overall_traffic_light": scan_service.determine_traffic_light(overall_risk_score),
"results": results
})
Loading