File size: 14,419 Bytes
46fba1d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
import os
import uuid
import pickle
from typing import List

try:
    from fastapi import FastAPI, File, Form, UploadFile, HTTPException
    from fastapi.middleware.cors import CORSMiddleware
except ImportError as exc:
    raise ImportError(
        "FastAPI is required to run this application. Install it with 'pip install fastapi'."
    ) from exc

import torch
import torchvision.models as models
import torchvision.transforms as transforms
from PIL import Image
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split  # Added for accuracy scoring
import io

# ── App Initialization ───────────────────────────────────────────────────────
app = FastAPI(title="Teachable Machine Backend")

# ── CORS Configuration ───────────────────────────────────────────────────────
# Enables file uploads and API calls from frontend (running on different origin/port)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # Allow requests from any origin
    allow_credentials=True,
    allow_methods=["*"],  # Allow all HTTP methods
    allow_headers=["*"],  # Allow all headers
)

DATASET_DIR = os.path.join(os.path.dirname(__file__), "dataset")
MODEL_PATH  = os.path.join(os.path.dirname(__file__), "model.pkl")


# ── Shared ML Setup (runs once at startup) ───────────────────────────────────
# Loading the model once here means every request reuses the same object in
# memory instead of reloading it from disk each time β€” much faster.
device = torch.device("cpu")

backbone = models.mobilenet_v3_small(weights=models.MobileNet_V3_Small_Weights.DEFAULT)
# Remove the final classifier layer β€” we only want feature extraction.
# The 960 numbers it outputs describe the image content without predicting a category.
backbone.classifier = torch.nn.Identity()
backbone.eval()  # Disables dropout β€” we are inferring, not training the backbone

# These values MUST be identical during training and prediction.
# 224x224 = the size MobileNetV3 was designed for.
# mean/std = ImageNet dataset statistics the model was pre-trained on.
transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225]
    )
])


# ── Helper: Extract features from a PIL image ────────────────────────────────
def extract_features(pil_image: Image.Image) -> np.ndarray:
    """

    Passes an image through MobileNetV3 and returns a 960-number feature vector.

    Used by both /train and /predict to guarantee identical preprocessing.

    """
    image = pil_image.convert("RGB")   # Handles RGBA/grayscale images safely
    tensor = transform(image)
    tensor = tensor.unsqueeze(0)       # (3,224,224) β†’ (1,3,224,224) β€” adds batch dim

    with torch.no_grad():              # No gradients needed β€” saves memory & time
        features = backbone(tensor)

    return features.squeeze().numpy() # (1,960) β†’ (960,) numpy array for sklearn


# ── Health Check ─────────────────────────────────────────────────────────────
@app.get("/")
def health_check():
    return {"status": "Backend is running!"}


# ── Milestone 1: Upload images ───────────────────────────────────────────────
@app.post("/upload-sample")
async def upload_sample(

    class_name: str = Form(...),

    files: List[UploadFile] = File(...)

):
    """

    Accepts a class label + a batch of images.

    Saves each image into dataset/<class_name>/ with a random UUID filename.

    """
    class_name = class_name.strip().replace(" ", "_")

    if not class_name:
        raise HTTPException(status_code=400, detail="class_name cannot be empty.")

    class_folder = os.path.join(DATASET_DIR, class_name)
    os.makedirs(class_folder, exist_ok=True)

    if not files:
        raise HTTPException(status_code=400, detail="At least one image file is required.")

    saved_files = []

    for file in files:
        if not file.content_type.startswith("image/"):
            raise HTTPException(
                status_code=400,
                detail=f"File '{file.filename}' is not an image. Only image files are accepted."
            )

        extension = os.path.splitext(file.filename)[1] or ".jpg"
        random_filename = f"{uuid.uuid4()}{extension}"
        save_path = os.path.join(class_folder, random_filename)

        contents = await file.read()
        with open(save_path, "wb") as f:
            f.write(contents)

        saved_files.append(random_filename)

    return {
        "message": f"Uploaded {len(saved_files)} image(s) to class '{class_name}'",
        "class": class_name,
        "saved_files": saved_files
    }


# ── Milestone 1 Bonus: Dataset info ─────────────────────────────────────────
@app.get("/dataset-info")
def dataset_info():
    if not os.path.exists(DATASET_DIR):
        return {"classes": {}, "total_images": 0}

    summary = {}
    for class_name in os.listdir(DATASET_DIR):
        class_path = os.path.join(DATASET_DIR, class_name)
        if os.path.isdir(class_path):
            summary[class_name] = len(os.listdir(class_path))

    return {
        "classes": summary,
        "total_images": sum(summary.values())
    }


# ── Milestone 2: Train model ─────────────────────────────────────────────────
@app.post("/train")
def train_model():
    """

    Scans dataset/, extracts MobileNetV3 features from every image,

    trains a LogisticRegression classifier, and saves it to model.pkl.

    """

    # ── Step 1: Validate dataset exists ──────────────────────────────────────
    if not os.path.exists(DATASET_DIR):
        raise HTTPException(
            status_code=400,
            detail="No dataset found. Please upload images first."
        )

    classes = [
        d for d in os.listdir(DATASET_DIR)
        if os.path.isdir(os.path.join(DATASET_DIR, d))
    ]

    # Classifier needs at least 2 classes β€” it learns to DISTINGUISH between them.
    # With only 1 class there is nothing to distinguish.
    if len(classes) < 2:
        raise HTTPException(
            status_code=400,
            detail=f"Need at least 2 classes to train. You currently have: {classes}"
        )

    X = []  # Feature vectors β€” one row per image
    y = []  # Labels         β€” one entry per image, matched by index to X

    # ── Step 2: Extract features from every image ────────────────────────────
    for class_name in classes:
        class_folder = os.path.join(DATASET_DIR, class_name)
        image_files  = os.listdir(class_folder)

        if len(image_files) == 0:
            continue  # Skip empty class folders silently

        for filename in image_files:
            image_path = os.path.join(class_folder, filename)
            try:
                img      = Image.open(image_path)
                features = extract_features(img)
                X.append(features)
                y.append(class_name)
            except Exception as e:
                # One corrupted image should not kill the whole training run
                print(f"Skipping {filename}: {e}")
                continue

    if len(X) == 0:
        raise HTTPException(
            status_code=400,
            detail="No valid images found in dataset."
        )

    X = np.array(X)  # Shape: (num_images, 960)
    y = np.array(y)  # Shape: (num_images,)

    # ── Step 3: Train the classifier ─────────────────────────────────────────
    
    # NEW: Split the data to calculate a real accuracy metric.
    # We added a safety net: if there are fewer than 5 images total, we test 
    # on the training data so it doesn't crash during a live presentation.
    if len(X) >= 5:
        X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
    else:
        X_train, X_test, y_train, y_test = X, X, y, y

    # Why LogisticRegression?
    # MobileNetV3 already converted images into meaningful 960-number vectors.
    # LogisticRegression just finds the boundary between those vectors.
    # It trains in under a second, works with very few images, and needs no GPU.
    # max_iter=1000 prevents ConvergenceWarning on small datasets.
    classifier = LogisticRegression(max_iter=1000)
    classifier.fit(X_train, y_train)
    
    # Calculate overall accuracy
    accuracy = classifier.score(X_test, y_test)

    # ── Step 4: Save classifier + class list to disk ─────────────────────────
    # We save classes explicitly so the /predict endpoint can map
    # numeric outputs back to human-readable label names.
    model_data = {
        "classifier": classifier,
        "classes": classes
    }
    with open(MODEL_PATH, "wb") as f:
        pickle.dump(model_data, f)

    return {
        "message": "Training complete!",
        "classes": classes,
        "total_images": len(X),
        "accuracy": round(accuracy * 100, 2),  # Returned safely to the frontend!
        "model_saved_at": MODEL_PATH
    }


# ── Milestone 3: Predict endpoint ────────────────────────────────────────────
@app.post("/predict")
async def predict(file: UploadFile = File(...)):
    """

    Accepts a single image, runs it through MobileNetV3 + the trained

    LogisticRegression classifier, and returns the predicted class

    with a confidence score for every class.

    """

    # ── Step 1: Check model exists ────────────────────────────────────────────
    # If the user hits /predict before ever running /train, model.pkl won't
    # exist yet. We catch this early with a clear message instead of a crash.
    if not os.path.exists(MODEL_PATH):
        raise HTTPException(
            status_code=400,
            detail="No trained model found. Please call /train first."
        )

    # ── Step 2: Validate the uploaded file is an image ────────────────────────
    if not file.content_type.startswith("image/"):
        raise HTTPException(
            status_code=400,
            detail=f"File '{file.filename}' is not an image. Only image files are accepted."
        )

    # ── Step 3: Load the saved model from disk ────────────────────────────────
    # We reload model.pkl on every prediction request.
    # Why not load it once at startup like the backbone?
    # Because model.pkl gets replaced every time /train is called.
    # If we cached it at startup, predictions would use the OLD model
    # even after the user retrains β€” a subtle but serious bug.
    with open(MODEL_PATH, "rb") as f:
        model_data = pickle.load(f)

    classifier = model_data["classifier"]
    classes    = model_data["classes"]

    # ── Step 4: Read and decode the uploaded image ────────────────────────────
    # file.read() gives us raw bytes. We wrap them in BytesIO so PIL
    # can treat the bytes like a file on disk β€” no temp file needed.
    contents = await file.read()
    image = Image.open(io.BytesIO(contents))

    # ── Step 5: Extract features using the SAME function used during training ─
    # This is the most important consistency rule in the whole project.
    # If training used 224x224 + ImageNet normalization, prediction MUST too.
    # extract_features() guarantees this since both phases call the same code.
    features = extract_features(image)

    # ── Step 6: Run prediction ────────────────────────────────────────────────
    # features is shape (960,) β€” we reshape to (1, 960) because sklearn
    # expects a 2D array: (number_of_samples, number_of_features)
    features_2d = features.reshape(1, -1)

    # predict() returns the winning class label e.g. ["cat"]
    predicted_class = classifier.predict(features_2d)[0]

    # predict_proba() returns confidence scores for ALL classes e.g. [0.82, 0.18]
    # Each number = how confident the model is that this image belongs to that class.
    # They always sum to 1.0 (100%).
    probabilities = classifier.predict_proba(features_2d)[0]

    # ── Step 7: Build a clean confidence scores dict ──────────────────────────
    # zip(classes, probabilities) pairs each class name with its score:
    # e.g. {"cat": 0.82, "dog": 0.18}
    # round(..., 4) keeps it readable: 0.8173 instead of 0.81734521938...
    # float() converts numpy float32 β†’ Python float so JSON can serialize it
    confidence_scores = {
        cls: round(float(prob), 4)
        for cls, prob in zip(classifier.classes_, probabilities)
    }

    return {
        "predicted_class": predicted_class,
        "confidence": round(float(max(probabilities)), 4),
        "all_scores": confidence_scores
    }