| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health_check(): | |
| return {"status": "Backend is running!"} | |
| # ββ Milestone 1: Upload images βββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |
| } |