What we're building

This project ties together almost everything from this path. Part 1 gave you images as tensors. Parts 2 to 4 gave you the building blocks of a CNN and the architectural ideas, ResNet among them, that made deep networks trainable. Part 5 gave you augmentation. Part 6 gave you transfer learning, and Part 12 walked through fine-tuning a ResNet on your own photos end to end. What has been missing so far is the last mile: turning a trained model into something another program, a phone app, or a colleague's script can actually call. That is what this project adds. We will train a classifier that looks at a photo of a plant leaf and predicts which disease, if any, it shows, then wrap that model in a small web service built with FastAPI, test it with real HTTP requests, and package it in a Docker container so it can run the same way on any machine.

The full pipeline has nine steps, and each one builds directly on something you have already practised:

  • Get a labelled dataset of leaf images organised into class folders
  • Load the data with augmentation for training and plain preprocessing for validation
  • Build a transfer-learning model from a pretrained ResNet18
  • Train in two phases: frozen backbone, then a short fine-tuning pass
  • Evaluate with more than plain accuracy, since classes are rarely balanced
  • Save the trained weights and the class mapping in a portable form
  • Write a FastAPI application that loads the model once and serves predictions
  • Test the running service with curl and with Python
  • Package everything in a Docker image so it runs anywhere

By the end you will have a working local API that accepts an uploaded photo and returns a predicted disease label with a confidence score, plus a container image you could hand to someone else to run without them installing anything beyond Docker. Nothing here requires a GPU, though training will be noticeably faster with one.

The dataset: leaf photos labelled by crop and disease

We will use a plant disease image dataset of the kind commonly distributed as PlantVillage, a widely used collection of leaf photographs for many crops, each photographed against a plain background and labelled with the crop and the specific disease, or labelled healthy if the leaf shows no symptoms. You can find it on Kaggle under a few different mirrors and repackagings; search for "PlantVillage" and pick a version that is already organised into one folder per class, with folder names like Tomato___Late_blight, Tomato___healthy, Potato___Early_blight, Corn___Common_rust, Apple___Cedar_apple_rust, and so on. The exact list of crops, diseases and the number of images per class varies a little between the mirrors, so check the folder listing yourself after downloading rather than relying on a fixed count; do not assume every version has identical totals.

This folder-per-class layout is exactly what torchvision's ImageFolder expects, which is the same tool you used in Part 12. That is one of the reasons this dataset is a good choice for a project like this: there is no custom parsing to write, the labels come for free from the directory structure, and the number of classes, typically several dozen crop-disease combinations, is large enough to make the evaluation step genuinely interesting rather than trivial.

One caution is worth stating plainly before you train anything. PlantVillage images are captured under controlled conditions: a single leaf, a plain or uniform background, consistent lighting. This makes the classification problem easier than it would be on a photo taken in a real field with soil, other leaves, shadows and uneven light in the frame. It is well documented in the plant pathology and agricultural computer vision literature that models trained and evaluated purely on this kind of dataset can perform very well on held-out images from the same collection while generalising far less well to photos taken outdoors with a phone. Keep that gap in mind throughout this project. We are building a solid, correctly engineered classifier and a correctly engineered API around it; we are not claiming the resulting model is ready to diagnose crops in a farmer's field without further validation on field photos. We will come back to this point in the limitations section near the end.

Download the dataset and arrange it so that a single root folder contains one subfolder per class, each full of jpg or png images, for example data/plantvillage/Tomato___healthy/, data/plantvillage/Tomato___Late_blight/, and so on. That root folder, data/plantvillage in our examples, is the only thing the training script needs to know about.

Setting up the project

The code in this article assumes Python 3.10 or newer, torch 2.x, torchvision 0.15 or newer (the pretrained-weights API used below, ResNet18_Weights, was introduced in torchvision 0.13, so anything from 0.15 onward is safe), fastapi 0.100 or newer, uvicorn 0.23 or newer, pillow 10 or newer, and scikit-learn 1.3 or newer for the evaluation metrics. FastAPI's file upload handling also needs the python-multipart package installed, even though you will not import it directly. If you are on an older or newer set of versions, the general shape of the code will still apply, but check the FastAPI and torchvision changelogs if something in this article does not match what you see locally.

💻Bash
pip install torch torchvision fastapi "uvicorn[standard]" pillow scikit-learn python-multipart numpy

If you prefer to pin versions in a requirements file, something like this is a reasonable starting point. Treat the lower bounds as a floor, not an exact match; newer patch versions within the same minor line should work fine.

📃Plain Text
torch>=2.0
torchvision>=0.15
fastapi>=0.100
uvicorn[standard]>=0.23
pillow>=10.0
scikit-learn>=1.3
python-multipart>=0.0.6
numpy>=1.24

Lay the project out as a plain directory with a training script, an API script, and the files the training script produces. You do not need a package or a complicated layout for this size of project.

📃Plain Text
plant-disease-api/
  data/
    plantvillage/
      Tomato___healthy/
      Tomato___Late_blight/
      Potato___Early_blight/
      ...
  train.py
  main.py
  requirements.txt
  Dockerfile
  idx_to_class.json          (created by train.py)
  plant_disease_resnet18.pt  (created by train.py)

Step 1: loading and splitting the images

As in Part 12, ImageFolder reads the directory structure and turns each subfolder name into a class label automatically, sorted alphabetically. We need two versions of the dataset object pointed at the same root folder: one with training-time augmentation, one without, because validation images should be evaluated exactly as they will appear in production, with no random flips or jitter. The trick is to build the split as a list of integer indices first, then wrap each transformed dataset in a Subset using those indices, so both the training subset and the validation subset are drawn from the same underlying files but see different transforms.

🐍Python
import numpy as np
import torch
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms

data_dir = "data/plantvillage"

imagenet_mean = [0.485, 0.456, 0.406]
imagenet_std = [0.229, 0.224, 0.225]

train_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(),
    transforms.RandomRotation(15),
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
    transforms.ToTensor(),
    transforms.Normalize(mean=imagenet_mean, std=imagenet_std),
])

val_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=imagenet_mean, std=imagenet_std),
])

# Build once, untransformed, just to read the class list and file count
base_dataset = datasets.ImageFolder(data_dir)
num_classes = len(base_dataset.classes)
print(f"Found {len(base_dataset)} images across {num_classes} classes")

indices = list(range(len(base_dataset)))
rng = np.random.default_rng(seed=42)
rng.shuffle(indices)

split_point = int(0.85 * len(indices))
train_indices = indices[:split_point]
val_indices = indices[split_point:]

train_full = datasets.ImageFolder(data_dir, transform=train_transform)
val_full = datasets.ImageFolder(data_dir, transform=val_transform)

train_dataset = Subset(train_full, train_indices)
val_dataset = Subset(val_full, val_indices)

train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=2)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=2)

A few details matter here. The two ColorJitter and rotation augmentations mirror what Part 5 covered: small, realistic distortions that teach the model to ignore lighting and camera-angle variation without teaching it to ignore the pattern of a lesion on a leaf. We fixed the shuffle seed before splitting so that the same 85 percent and 15 percent split is reproducible if you rerun the script. The normalization constants, imagenet_mean and imagenet_std, are not arbitrary; they are the per-channel mean and standard deviation that the original ResNet was trained with on ImageNet, and reusing them keeps the pixel statistics the pretrained weights expect, exactly as discussed in Part 6. If you forget this step, or use different numbers at inference time than you used at training time, the model will see inputs that look statistically unlike anything it learned from, and predictions will degrade in a way that is easy to miss and hard to diagnose later. Keep a mental note of this; it comes back as the single most common deployment bug later in this article. If you are on Windows and running this as a script rather than inside a notebook, wrap the DataLoader-using code in an if __name__ == "__main__": guard, because num_workers greater than zero spawns subprocesses that need that guard to avoid re-importing your script recursively.

Step 2: building the model with transfer learning

We will reuse the same transfer-learning recipe from Part 6 and Part 12: start from a ResNet18 pretrained on ImageNet, keep its convolutional feature extractor, and replace only the final fully connected layer with a new one sized for our number of plant-disease classes. ResNet18 is a reasonable choice for this project because it is small enough to fine-tune quickly on a laptop CPU if needed, and its skip connections, the idea covered in Part 4, make it a stable, well-behaved backbone for transfer learning across a wide range of image domains, leaf photos included.

🐍Python
import torch.nn as nn
from torchvision import models

def build_model(num_classes: int) -> nn.Module:
    weights = models.ResNet18_Weights.DEFAULT
    model = models.resnet18(weights=weights)

    # Freeze every pretrained parameter to start with
    for param in model.parameters():
        param.requires_grad = False

    # Replace the classification head; new layers require grad by default
    in_features = model.fc.in_features
    model.fc = nn.Linear(in_features, num_classes)

    return model

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = build_model(num_classes).to(device)
print(model.fc)

ResNet18_Weights.DEFAULT loads the current recommended set of ImageNet weights bundled with torchvision; using weights=None later, inside the API code, will give you the same architecture with randomly initialised parameters, which is what we want once we are loading our own trained weights instead. Freezing every parameter before replacing model.fc means the new linear layer is the only part of the network with requires_grad set to True at this point, since newly created layers default to trainable. That matters directly for the next step, because an optimiser built from model.fc.parameters() will only ever touch that one layer during the first training phase, leaving the pretrained convolutional filters untouched.

Step 3: training in two phases

Phase one trains only the new classification head while the backbone stays frozen. This is fast, because gradients do not need to be computed or stored for most of the network, and it is safe, because the pretrained filters that already know how to detect edges, textures and colour patterns are left exactly as they were. Write one small function that runs a single epoch, either in training mode or evaluation mode, so the same code handles both loops.

🐍Python
import torch.optim as optim

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)

def run_epoch(loader, train: bool):
    model.train() if train else model.eval()
    total_loss, total_correct, total_samples = 0.0, 0, 0

    with torch.set_grad_enabled(train):
        for images, labels in loader:
            images, labels = images.to(device), labels.to(device)

            if train:
                optimizer.zero_grad()

            outputs = model(images)
            loss = criterion(outputs, labels)

            if train:
                loss.backward()
                optimizer.step()

            total_loss += loss.item() * images.size(0)
            preds = outputs.argmax(dim=1)
            total_correct += (preds == labels).sum().item()
            total_samples += images.size(0)

    return total_loss / total_samples, total_correct / total_samples

import copy

best_val_acc = 0.0
best_weights = copy.deepcopy(model.state_dict())

phase1_epochs = 8
for epoch in range(phase1_epochs):
    train_loss, train_acc = run_epoch(train_loader, train=True)
    val_loss, val_acc = run_epoch(val_loader, train=False)
    print(f"[phase 1] epoch {epoch+1}/{phase1_epochs} "
          f"train_loss={train_loss:.4f} train_acc={train_acc:.4f} "
          f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}")
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        best_weights = copy.deepcopy(model.state_dict())

Watch the printed numbers as training runs. Training loss should fall steadily. Training accuracy climbing well above validation accuracy over several epochs is the usual sign of overfitting; with the backbone frozen and only one linear layer being trained, this phase tends to be fairly stable and not prone to the sharp overfitting you can get when fine-tuning a whole large network on a small dataset. We save a deep copy of the best-performing weights by validation accuracy rather than just keeping whatever the model looks like after the last epoch, since the last epoch is not always the best one.

Phase two unfreezes the last residual block, layer4, together with the classification head, and continues training with a much smaller learning rate. The reasoning, covered in Part 6, is that the earliest layers of a pretrained CNN learn very general features, edges, colour gradients, simple textures, that are useful for almost any image task, while the later layers learn features more specific to the original training data. Allowing the last block to adapt slightly to leaf photography, without touching the early layers or using a learning rate large enough to erase what they already know, usually buys a small but real improvement in accuracy.

🐍Python
for name, param in model.named_parameters():
    if name.startswith("layer4") or name.startswith("fc"):
        param.requires_grad = True

optimizer = optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()),
    lr=1e-4,
)

phase2_epochs = 5
for epoch in range(phase2_epochs):
    train_loss, train_acc = run_epoch(train_loader, train=True)
    val_loss, val_acc = run_epoch(val_loader, train=False)
    print(f"[phase 2] epoch {epoch+1}/{phase2_epochs} "
          f"train_loss={train_loss:.4f} train_acc={train_acc:.4f} "
          f"val_loss={val_loss:.4f} val_acc={val_acc:.4f}")
    if val_acc > best_val_acc:
        best_val_acc = val_acc
        best_weights = copy.deepcopy(model.state_dict())

model.load_state_dict(best_weights)
print(f"Best validation accuracy: {best_val_acc:.4f}")

Notice the learning rate dropped by a factor of ten between phases, from 1e-3 to 1e-4. That is deliberate: the parameters we have just unfrozen are already well initialised by pretraining, and a large learning rate applied to them risks undoing that initialisation within a few batches, a failure mode sometimes called catastrophic forgetting. After phase two finishes, we reload best_weights one more time so that model holds the single best checkpoint seen across both phases, not whatever state it happened to end in. Because PlantVillage images are visually uniform, as discussed earlier, do not be surprised if validation accuracy climbs quite high, possibly above 90 percent, within a handful of epochs. Treat a very high number as a reflection of how controlled the images are rather than proof the model will perform equally well on a photo taken outdoors; the evaluation and limitations sections below say more about this.

Step 4: evaluating properly, not just accuracy

A single accuracy number can hide a lot. Here is a small worked example to make that concrete. Imagine a validation set of 100 leaf images, 90 of which are healthy and 10 of which show a disease. A classifier that ignores the image entirely and always predicts healthy would score 90 percent accuracy while being completely useless for the one job that matters, catching the diseased leaves. Real plant-disease datasets are rarely that extreme, but they are commonly imbalanced, some diseases are simply rarer or harder to photograph than others, so it is worth checking performance per class rather than trusting one overall percentage.

🐍Python
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np

model.eval()
all_preds, all_labels = [], []

with torch.no_grad():
    for images, labels in val_loader:
        images = images.to(device)
        outputs = model(images)
        preds = outputs.argmax(dim=1).cpu().numpy()
        all_preds.extend(preds)
        all_labels.extend(labels.numpy())

class_names = base_dataset.classes
print(classification_report(all_labels, all_preds, target_names=class_names))

cm = confusion_matrix(all_labels, all_preds)
print("Confusion matrix shape:", cm.shape)

classification_report prints precision, recall and F1-score for every class, which is far more informative than a single accuracy figure. Precision for a class answers "when the model says this disease, how often is it right", recall answers "of all the leaves that actually have this disease, how many did the model catch". A class with low recall and decent precision is one the model is cautious about and tends to miss; a class with low precision is one the model over-predicts, mistaking other diseases for it. The confusion matrix goes one level deeper: row i, column j counts how many images of true class i were predicted as class j. Large off-diagonal values tell you exactly which pairs of diseases the model confuses, which is often genuinely informative biologically, since some diseases produce visually similar lesions, early blight and late blight on the same crop being a plausible example of two classes that are easy for a model, and sometimes for a person, to mix up.

Beyond the numbers, it is worth looking at a handful of misclassified images directly: pull a few indices where all_preds differs from all_labels, load the corresponding original image, and view it alongside the predicted and true class names. This quick manual check often reveals practical issues a table of numbers will not, a blurry photo, a leaf photographed from an unusual angle, two diseases that genuinely look alike even to a trained eye. If you want to go further, the Grad-CAM technique from Part 11 is a natural next step here: running it on a few misclassified images tells you whether the model was looking at the lesion on the leaf or latching onto something irrelevant, like a shadow or the edge of a pot, which is a common and easy mistake to miss if you only look at accuracy numbers.

Step 5: saving the model for inference

Save two things, not one: the trained weights, and the mapping from the integer class index the model outputs to the human-readable class name. The second file is easy to forget and causes real problems if skipped, because ImageFolder's alphabetical ordering of class folders is what defines which integer corresponds to which disease, and that ordering is not guaranteed to be obvious or memorable months later. Save torch.save(model.state_dict(), ...) rather than pickling the whole model object; a state dict is just a dictionary of tensors, which is more portable across code and library versions and avoids the security and compatibility pitfalls of unpickling an entire Python object graph written by a different version of your code.

🐍Python
import json

torch.save(model.state_dict(), "plant_disease_resnet18.pt")

idx_to_class = {idx: name for name, idx in base_dataset.class_to_idx.items()}
with open("idx_to_class.json", "w") as f:
    json.dump(idx_to_class, f, indent=2)

print("Saved model weights and class mapping.")
print(idx_to_class)

These two files, plant_disease_resnet18.pt and idx_to_class.json, are now everything the API needs. You could hand just these two files, plus the model-building code, to someone else and they would be able to reproduce predictions exactly, without needing the training images or the training script at all.

Step 6: building the API with FastAPI

FastAPI is a Python web framework built specifically around type hints: you describe the shape of a request using ordinary Python types, and FastAPI handles the request parsing, validation, and error responses for you, while also generating interactive API documentation automatically. It has become a common choice for serving machine learning models because it is fast to write, runs well with async I/O, which matters if your service ever needs to handle many simultaneous requests, and because the automatically generated documentation page gives you a working test client for free, without writing a single line of frontend code.

The plan for main.py is simple: load the model and the class mapping once, when the module is imported, not once per request; define a preprocessing pipeline that matches the validation transform exactly, with no augmentation; and define an endpoint that accepts an uploaded image file, runs it through the model, and returns the predicted class and a confidence score as JSON.

🐍Python
import io
import json

import torch
import torch.nn as nn
import torch.nn.functional as F
from fastapi import FastAPI, File, UploadFile, HTTPException
from PIL import Image
from torchvision import models, transforms

app = FastAPI(title="Plant Disease Classifier")

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

with open("idx_to_class.json") as f:
    idx_to_class = {int(k): v for k, v in json.load(f).items()}

num_classes = len(idx_to_class)


def build_model(num_classes: int) -> nn.Module:
    model = models.resnet18(weights=None)
    in_features = model.fc.in_features
    model.fc = nn.Linear(in_features, num_classes)
    return model


model = build_model(num_classes)
model.load_state_dict(torch.load("plant_disease_resnet18.pt", map_location=device))
model.to(device)
model.eval()

imagenet_mean = [0.485, 0.456, 0.406]
imagenet_std = [0.229, 0.224, 0.225]

preprocess = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=imagenet_mean, std=imagenet_std),
])


@app.get("/")
def root():
    return {
        "status": "ok",
        "message": "Plant disease classifier API. POST an image to /predict.",
    }


@app.post("/predict")
async def predict(file: UploadFile = File(...)):
    if file.content_type not in ("image/jpeg", "image/png"):
        raise HTTPException(status_code=400, detail="Please upload a JPEG or PNG image.")

    image_bytes = await file.read()
    try:
        image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
    except Exception:
        raise HTTPException(status_code=400, detail="Could not read the image file.")

    tensor = preprocess(image).unsqueeze(0).to(device)

    with torch.no_grad():
        logits = model(tensor)
        probabilities = F.softmax(logits, dim=1)[0]
        top_prob, top_idx = torch.max(probabilities, dim=0)

    return {
        "predicted_class": idx_to_class[top_idx.item()],
        "confidence": round(top_prob.item(), 4),
    }

Walk through the pieces that matter most. The model and idx_to_class mapping are loaded at module level, meaning they load exactly once when uvicorn starts the application, not on every incoming request; loading a ResNet from disk takes a noticeable fraction of a second, and doing that per request would make the service slow and wasteful. build_model here uses weights=None, unlike the training script, because we are about to overwrite every parameter with our own trained weights anyway; there is no point downloading and then immediately discarding the ImageNet weights. model.eval() switches batch normalization and dropout, where present, into inference behaviour, using running statistics rather than batch statistics; forgetting this call is a classic bug that produces inconsistent, batch-size-dependent predictions. The preprocess pipeline deliberately matches val_transform from training exactly, same resize, same normalization constants, with no random augmentation, because the model was trained to expect inputs with that specific statistical shape. The predict endpoint validates the content type before doing any work, reads the uploaded bytes asynchronously, decodes them into a Pillow image, converts to RGB to guard against grayscale or RGBA images breaking the three-channel assumption the model expects, runs the forward pass inside torch.no_grad() to skip building a computation graph we will never backpropagate through, applies softmax to turn raw logits into probabilities that sum to one, and returns the single most likely class together with its probability as the confidence score.

Step 7: running and testing the API

Start the service with uvicorn, pointing it at the app object inside main.py. The --reload flag is convenient during development, since it restarts the server automatically whenever you edit the file, but drop it for anything resembling production use.

💻Bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000

Once it is running, open http://127.0.0.1:8000/docs in a browser. FastAPI generates an interactive Swagger UI page automatically from your endpoint definitions, listing both the root route and /predict, complete with a file picker you can use to try a prediction without writing any client code at all. This is a genuinely useful sanity check before scripting a test client: upload a known healthy leaf photo and a known diseased one and see whether the predictions look reasonable.

To test from the command line, curl's -F flag sends a multipart form upload, which is what the UploadFile parameter on the server side expects.

💻Bash
curl -X POST "http://127.0.0.1:8000/predict" -F "file=@test_leaf.jpg;type=image/jpeg"

And from Python, the requests library makes the same call with a little more structure around the response, which is closer to how another application would actually integrate with this service.

🐍Python
import requests

url = "http://127.0.0.1:8000/predict"

with open("test_leaf.jpg", "rb") as f:
    files = {"file": ("test_leaf.jpg", f, "image/jpeg")}
    response = requests.post(url, files=files)

print(response.status_code)
print(response.json())

A successful call returns status code 200 and a small JSON object, something like predicted_class set to a string such as Tomato___Late_blight and confidence set to a number between 0 and 1, for example 0.93. Keep in mind that this confidence is simply the softmax probability the model assigned to its top prediction, not a calibrated estimate of how likely the prediction is to be correct in any statistically rigorous sense; a model can be confidently wrong, particularly on inputs that look nothing like its training data, a point we return to below.

Step 8: a few production-minded additions

A single top-1 prediction is often not enough for a real application. Showing the top three or five candidate diseases, each with its own probability, gives a user more useful information, especially when two diseases are visually similar and the model itself is genuinely uncertain between them. torch.topk makes this straightforward to add as a second endpoint.

🐍Python
@app.post("/predict/topk")
async def predict_topk(file: UploadFile = File(...), k: int = 3):
    if file.content_type not in ("image/jpeg", "image/png"):
        raise HTTPException(status_code=400, detail="Please upload a JPEG or PNG image.")

    image_bytes = await file.read()
    image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
    tensor = preprocess(image).unsqueeze(0).to(device)

    with torch.no_grad():
        logits = model(tensor)
        probabilities = F.softmax(logits, dim=1)[0]
        top_probs, top_idxs = torch.topk(probabilities, k)

    results = [
        {"class": idx_to_class[idx.item()], "confidence": round(prob.item(), 4)}
        for prob, idx in zip(top_probs, top_idxs)
    ]
    return {"predictions": results}

Note that k here is a plain integer parameter that is not part of the file upload, so FastAPI automatically treats it as a query parameter with a default of 3; a client can call /predict/topk?k=5 to get five candidates instead. A few other additions are worth considering even for a small project like this one. Validate file size as well as file type, since UploadFile does not enforce a maximum size by default, and a very large image could tie up memory and CPU time unnecessarily; a simple check on len(image_bytes) before decoding is enough for most cases. Decide what the service should do when the model is simply unsure, for example by returning an explicit "uncertain" result whenever the top confidence falls below some threshold you choose by looking at the validation set, rather than silently returning a low-confidence guess as if it were a confident answer. Log each prediction, even just to standard output with a timestamp, the predicted class and the confidence; this is the cheapest form of monitoring you can set up, and it is what lets you notice later if the distribution of predictions drifts, which can be an early sign that the kind of photos users are sending no longer resembles the training data.

If you ever call this API from a web page running in a browser rather than from curl or a backend script, you will need to allow cross-origin requests explicitly, since browsers block requests to a different host and port by default. FastAPI's CORSMiddleware handles this.

🐍Python
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

allow_origins=["*"] is fine for local testing but far too permissive for a real deployment; in production, list the specific origins, meaning the specific web addresses, that should be allowed to call the API.

Step 9: packaging the service with Docker

A container packages your code, your Python dependencies, and a minimal operating system layer into one image that runs the same way on your laptop, a colleague's machine, or a cloud server, regardless of what else is installed on that machine. For a small inference service like this one, a slim Python base image is a sensible starting point; we are not training inside the container, only running inference, so there is no need for a large image with CUDA toolkits unless you specifically plan to run this container on a GPU-equipped host.

📃Plain Text
FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py .
COPY plant_disease_resnet18.pt .
COPY idx_to_class.json .

EXPOSE 8000

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

Build and run it with the standard Docker commands. The -p flag maps port 8000 inside the container to port 8000 on your machine, so the service is reachable at the same address as before.

💻Bash
docker build -t plant-disease-api .
docker run -p 8000:8000 plant-disease-api

Once the container is running, the curl and Python test calls from Step 7 work completely unchanged, since from the outside the service looks identical to the one you ran directly with uvicorn. A couple of practical notes: the trained weights file is baked directly into the image here for simplicity, which is fine for a model of this size, typically a few tens of megabytes for a ResNet18 head-plus-backbone, but for much larger models you would more commonly mount the weights file as a volume or download it from storage at container start-up, so that updating the model does not require rebuilding the entire image. And if you do plan to run this on a GPU host, you would switch to an appropriate CUDA-enabled base image and install the matching GPU build of torch, which is a different set of install instructions than the ones used here.

Limitations to keep in mind

The domain gap mentioned earlier deserves to be taken seriously rather than treated as a footnote. A model trained entirely on PlantVillage-style images, single leaves photographed against plain backgrounds under even lighting, has learned to recognise lesion patterns in that specific visual setting. A photo taken in a field, with multiple leaves overlapping, variable sunlight, soil and other plants in the frame, is a meaningfully different input distribution. The model will still produce an output for such a photo, because softmax always returns some probability distribution over the known classes, but that output can be confidently wrong. If you intend to use a classifier like this beyond a learning project, plan to collect and evaluate on field-realistic photos before trusting its predictions, and consider training with more aggressive augmentation, background variation, or additional field-collected data if it is available.

A related issue is the open-set problem: the model can only ever predict one of the classes it was trained on. A photo of a healthy leaf from a crop species that was never in the training set, a disease that simply is not represented in your data, or even a photo that is not a plant leaf at all, a photo of a dog, say, will still be forced into one of the known classes, because softmax normalises probabilities over a fixed, closed set of outputs; there is no built-in notion of "none of the above". A low top-1 confidence score is a weak signal that something unusual is happening, which is part of why the confidence threshold idea in Step 8 is worth implementing rather than skipping.

Class imbalance, touched on in the evaluation section, affects not just the reported metrics but the model's actual behaviour: classes with few training examples tend to be learned less reliably, and the model may be biased toward predicting the more common classes when genuinely uncertain. If you expand this project to a dataset of your own, pay attention to how many images each class has and consider techniques such as class-weighted loss or oversampling rare classes if the imbalance is severe.

Finally, a model deployed once is not a model deployed forever. If this service were used in practice, the sensible approach would include periodically reviewing a sample of the images it is asked to classify, checking whether predictions still look reasonable, and retraining on new data as it becomes available, rather than assuming the model trained today will remain accurate indefinitely.

Common mistakes

  • Using different preprocessing at inference time than at training time, especially different resize dimensions or different normalization mean and standard deviation values; this is the single most common and hardest-to-notice bug in deployed vision models, since the model still runs without errors, it just predicts badly
  • Forgetting model.eval() before running inference, which leaves batch normalization and dropout in training mode and makes predictions depend on batch composition in ways that make no sense for a single uploaded image
  • Hardcoding the mapping from class index to class name instead of saving and loading idx_to_class.json; ImageFolder's alphabetical ordering is easy to get wrong from memory, especially after adding or removing a class folder
  • Returning raw logits instead of applying softmax, which gives numbers that are not probabilities and cannot be meaningfully compared to a confidence threshold
  • Loading the model inside the request handler instead of once at module load time, which makes every single prediction slow and wastes memory
  • Skipping input validation, so a non-image file, a corrupted upload, or an unsupported file type crashes the endpoint with an unhandled exception instead of a clean 400 error
  • Allowing allow_origins=["*"] in CORS settings to remain in a real deployment rather than restricting it to known origins
  • Not pinning library versions anywhere, which risks a torchvision or FastAPI update silently changing default behaviour between when you trained the model and when you deploy it
  • Trusting a single overall accuracy number on an imbalanced multi-class dataset instead of checking per-class precision and recall

Summary and what's next

You built a complete, working pipeline: a transfer-learning classifier fine-tuned in two phases on leaf images, evaluated with per-class metrics rather than a single misleading accuracy figure, saved in a portable form alongside its class mapping, wrapped in a FastAPI service with sensible input validation and a top-k endpoint, and packaged into a Docker image that runs the same way anywhere Docker runs. That is the same shape most real computer vision projects take, whether the subject is plant disease, medical imaging, manufacturing defects, or anything else: train, evaluate honestly, export cleanly, serve safely, and keep the deployment gap, the difference between your training data and the real world, firmly in view.

This project is also a natural place to apply ideas from earlier parts of this path if you want to extend it further. Part 11's Grad-CAM technique could become a third endpoint that returns a heatmap alongside the prediction, letting a user see which part of the leaf the model actually looked at. Parts 7 and 8, on detection and segmentation, point toward a more ambitious version of this project that does not just classify a whole leaf but localises individual lesions within the image, which is closer to how a plant pathologist actually examines a sample. Part 9's vision transformers and Part 10's self-supervised pretraining are both worth trying as alternatives to the ResNet18 backbone used here, particularly if you have access to a larger or less neatly labelled dataset where self-supervised pretraining on unlabelled field photos, followed by fine-tuning on a smaller labelled set, might close some of the domain gap discussed above. Whichever direction you take it, the pattern you practised here, a trained model wrapped in a tested, documented API, is one you will reuse on nearly every computer vision project that needs to leave a notebook and reach an actual user.