Step-by-step builds

Image Classification Project in Python: Transfer Learning With PyTorch

Satvik ParamkushamUpdated October 5, 202612 min read

Quick answer

An image classification project trains a model to sort images into categories, such as healthy or diseased leaves. The fastest reliable method is transfer learning: take a CNN like ResNet18 pretrained on ImageNet, replace its last layer with one for your classes, and train on a few hundred images per class. A free Colab GPU finishes training in minutes.

Image classification is the "hello world" of computer vision, but it does not have to be a toy. With transfer learning, a few hundred photos per class and a free GPU, you can build a classifier for plant diseases, waste sorting or road damage that performs well enough to demo with real photos.

This guide builds one in PyTorch, step by step. You will organise your images in folders, load a pretrained ResNet18 from torchvision, train a new output layer, fine-tune deeper layers, and evaluate with accuracy and a confusion matrix. The code is around 120 lines in total and works on any image dataset arranged as one folder per class.

You do not need to design a CNN from scratch. Knowing why you did not, and being able to explain what the pretrained layers already know, is exactly what a viva panel will test.

What is an image classification project?

An image classification project builds a model that looks at an image and assigns it one label from a fixed set of classes. Examples: "tomato early blight" versus "tomato healthy", or "plastic" versus "paper" versus "metal". It is one of the core tasks of computer vision.

Most modern image classifiers are convolutional neural networks (CNNs) or vision transformers. Training one from scratch needs a very large labelled dataset. Transfer learning avoids that: you start from a network already trained on ImageNet (over a million images in 1,000 classes), keep the layers that detect edges, textures and shapes, and retrain only the final layer for your classes.

If neural networks are new to you, read what a neural network is first. The rest of this guide assumes you know what layers and weights are.

What do you need for an image classification project in Python?

You need Python basics, an image dataset arranged as one folder per class, and ideally a GPU. Google Colab's free tier offers GPU access, though Google states that free resources are not guaranteed and usage limits fluctuate.

PartWhat we useWhy
FrameworkPyTorch and torchvisionPretrained models and image tools in one place
ModelResNet18 with ResNet18_Weights.DEFAULTSmall, fast, strong baseline
Data loadingtorchvision.datasets.ImageFolderReads one folder per class, no CSV needed
Metricsscikit-learn classification_report and confusion matrixPer-class precision, recall and F1
ComputeGoogle Colab GPU or your own GPUA CPU works for small datasets, just slower

On Colab: open a new notebook, go to the Runtime menu, choose Change runtime type, and select a GPU. Upload your dataset as a zip or mount Google Drive.

How does transfer learning for image classification work?

Transfer learning reuses a pretrained network and trains it in two phases:

  1. Prepare data: resize images to 224 by 224 pixels, normalise colours the way ImageNet models expect, and add random flips and crops to the training set (data augmentation).
  2. Phase 1, feature extraction: freeze every pretrained layer and train only a new final layer with one output per class. Fast, and hard to overfit.
  3. Phase 2, fine-tuning: unfreeze the last block of layers and train it with a small learning rate, so the network adapts its high-level features to your images.
  4. Evaluate: accuracy on a validation split the model never trained on, plus a confusion matrix that shows which classes get mixed up.

The validation split is the honest judge. Training accuracy near 100 percent with much lower validation accuracy is overfitting, and augmentation plus freezing layers are your main defences against it.

How to build an image classifier with PyTorch, step by step

  1. Step 1. Set up PyTorch and torchvision

    On your own machine, create an environment and install the libraries. On Colab, torch and torchvision are usually preinstalled.

    mkdir image-classifier && cd image-classifier
    python -m venv .venv
    # Windows: .venv\Scripts\activate    macOS/Linux: source .venv/bin/activate
    pip install torch torchvision scikit-learn matplotlib pillow
    python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

    If the last line prints True, PyTorch can see your GPU. If you need a specific CUDA build, use the install selector on the official PyTorch website.

  2. Step 2. Organise the dataset into class folders

    ImageFolder expects one subfolder per class, with the folder name as the label.

    data/
      tomato_early_blight/
        img001.jpg
        img002.jpg
      tomato_healthy/
        img001.jpg
      tomato_leaf_mold/
        img001.jpg

    For a plant disease project, PlantVillage is the usual starting point: TensorFlow Datasets describes it as 54,303 leaf images in 38 classes by species and disease, and notes the original source site no longer hosts it, so use a mirror such as the one listed there or a Kaggle copy. Start with 3 to 5 classes from one crop. Fewer, well-chosen classes give a cleaner project than all 38 at once, and train much faster.

    Check the class balance before training. If one class has ten times the images of another, accuracy will flatter the model, and per-class recall becomes the number to watch.

  3. Step 3. Load the data with transforms and a train/validation split

    Training images get random augmentation; validation images get only the standard resize, crop and normalisation that the pretrained weights expect.

    # data.py
    import torch
    from torch.utils.data import DataLoader, Subset
    from torchvision import datasets, transforms
    from torchvision.models import ResNet18_Weights
    
    MEAN, STD = [0.485, 0.456, 0.406], [0.229, 0.224, 0.225]  # ImageNet statistics
    train_tf = transforms.Compose([
        transforms.RandomResizedCrop(224),
        transforms.RandomHorizontalFlip(),
        transforms.ColorJitter(0.2, 0.2, 0.2),
        transforms.ToTensor(),
        transforms.Normalize(MEAN, STD),
    ])
    val_tf = ResNet18_Weights.DEFAULT.transforms()  # resize, center crop, normalise
    
    def get_loaders(root="data", val_frac=0.2, batch_size=32):
        train_full = datasets.ImageFolder(root, transform=train_tf)
        val_full = datasets.ImageFolder(root, transform=val_tf)
        g = torch.Generator().manual_seed(42)
        idx = torch.randperm(len(train_full), generator=g).tolist()
        n_val = int(len(idx) * val_frac)
        train_ds, val_ds = Subset(train_full, idx[n_val:]), Subset(val_full, idx[:n_val])
        return (DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=2),
                DataLoader(val_ds, batch_size=batch_size, num_workers=2),
                train_full.classes)

    Two ImageFolder objects over the same folder, split with the same shuffled indices, let each split use its own transforms. The fixed seed means every script sees the same validation images.

  4. Step 4. Load a pretrained ResNet18 and replace the head

    torchvision downloads the ImageNet weights on first use. We freeze all layers, then swap the final fully connected layer for one with an output per class.

    # model.py
    import torch.nn as nn
    from torchvision.models import resnet18, ResNet18_Weights
    
    def build_model(num_classes, freeze=True):
        model = resnet18(weights=ResNet18_Weights.DEFAULT)
        if freeze:
            for p in model.parameters():
                p.requires_grad = False
        model.fc = nn.Linear(model.fc.in_features, num_classes)  # new layer, trainable
        return model

    The new layer is created after freezing, so it is the only part with requires_grad set to True. ResNet18 has about 11.7 million parameters (listed in the torchvision docs); in this phase you train only a few thousand of them.

  5. Step 5. Train the new layer and save the best model

    One function handles both training and validation, so the two loops cannot drift apart.

    # train.py
    import torch
    import torch.nn as nn
    from data import get_loaders
    from model import build_model
    
    def run_epoch(model, loader, loss_fn, device, optimizer=None):
        training = optimizer is not None
        model.train(training)
        total, correct, loss_sum = 0, 0, 0.0
        with torch.set_grad_enabled(training):
            for x, y in loader:
                x, y = x.to(device), y.to(device)
                out = model(x)
                loss = loss_fn(out, y)
                if training:
                    optimizer.zero_grad()
                    loss.backward()
                    optimizer.step()
                loss_sum += loss.item() * len(y)
                correct += (out.argmax(1) == y).sum().item()
                total += len(y)
        return loss_sum / total, correct / total
    
    if __name__ == "__main__":
        device = "cuda" if torch.cuda.is_available() else "cpu"
        train_dl, val_dl, classes = get_loaders()
        model = build_model(len(classes)).to(device)
        optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)
        loss_fn, best = nn.CrossEntropyLoss(), 0.0
        for epoch in range(5):
            _, tr_acc = run_epoch(model, train_dl, loss_fn, device, optimizer)
            va_loss, va_acc = run_epoch(model, val_dl, loss_fn, device)
            print(f"epoch {epoch + 1}: train {tr_acc:.3f}  val {va_acc:.3f}  val loss {va_loss:.3f}")
            if va_acc > best:
                best = va_acc
                torch.save({"state": model.state_dict(), "classes": classes}, "best_model.pt")

    The if __name__ == "__main__" guard is needed on Windows when DataLoader uses worker processes. Log the printed numbers for your report, and plot them if you have time: the gap between train and val accuracy tells the overfitting story.

  6. Step 6. Fine-tune the last block for extra accuracy

    Once the new layer has learned, unfreeze the last residual block (layer4) and train both with a smaller learning rate for that block. In train.py, after the first loop finishes, add:

    for p in model.layer4.parameters():
        p.requires_grad = True
    
    optimizer = torch.optim.Adam([
        {"params": model.layer4.parameters(), "lr": 1e-4},
        {"params": model.fc.parameters(), "lr": 1e-3},
    ])
    for epoch in range(5):
        _, tr_acc = run_epoch(model, train_dl, loss_fn, device, optimizer)
        va_loss, va_acc = run_epoch(model, val_dl, loss_fn, device)
        print(f"fine-tune {epoch + 1}: train {tr_acc:.3f}  val {va_acc:.3f}")
        if va_acc > best:
            best = va_acc
            torch.save({"state": model.state_dict(), "classes": classes}, "best_model.pt")

    The smaller learning rate stops large updates from wrecking the pretrained features. Report validation accuracy after phase 1 and after phase 2 separately: that comparison is a ready-made results table.

  7. Step 7. Evaluate with a classification report and confusion matrix

    Accuracy is one number. The confusion matrix shows which classes the model confuses, which is where the real insight sits.

    # evaluate.py
    import torch
    import matplotlib.pyplot as plt
    from sklearn.metrics import classification_report, ConfusionMatrixDisplay
    from data import get_loaders
    from model import build_model
    
    if __name__ == "__main__":
        device = "cuda" if torch.cuda.is_available() else "cpu"
        _, val_dl, classes = get_loaders()
        ckpt = torch.load("best_model.pt", map_location=device)
        model = build_model(len(classes)).to(device)
        model.load_state_dict(ckpt["state"])
        model.eval()
    
        preds, labels = [], []
        with torch.no_grad():
            for x, y in val_dl:
                preds += model(x.to(device)).argmax(1).cpu().tolist()
                labels += y.tolist()
    
        print(classification_report(labels, preds, target_names=classes))
        ConfusionMatrixDisplay.from_predictions(labels, preds, display_labels=classes,
                                                xticks_rotation=45)
        plt.tight_layout()
        plt.savefig("confusion_matrix.png", dpi=150)

    Put confusion_matrix.png in your report and explain the largest off-diagonal cell. "Early blight and late blight get confused because both show brown lesions" is the kind of sentence that earns marks.

  8. Step 8. Predict on a new photo

    A prediction script turns the model into something you can demo with a fresh photo taken on your phone.

    # predict.py
    import sys
    import torch
    from PIL import Image
    from torchvision.models import ResNet18_Weights
    from model import build_model
    
    ckpt = torch.load("best_model.pt", map_location="cpu")
    model = build_model(len(ckpt["classes"]))
    model.load_state_dict(ckpt["state"])
    model.eval()
    tf = ResNet18_Weights.DEFAULT.transforms()
    
    img = Image.open(sys.argv[1]).convert("RGB")
    with torch.no_grad():
        probs = torch.softmax(model(tf(img).unsqueeze(0)), dim=1)[0]
    top = probs.topk(min(3, len(probs)))
    for p, i in zip(top.values.tolist(), top.indices.tolist()):
        print(f"{ckpt['classes'][i]}: {p:.1%}")

    Run python predict.py leaf.jpg. Test it on photos taken outside the dataset. The drop in confidence you usually see on real-world photos is worth a paragraph in your limitations section.

What are good image classification project ideas?

The code above works for any folder-per-class dataset. Ideas with a clear real-world angle:

  • Plant disease detection: PlantVillage classes for tomato, potato or another crop. Note in your report that PlantVillage photos were taken against plain backgrounds, so field photos are harder.
  • Waste classification: sort images into plastic, paper, glass, metal and organic. Public sets such as TrashNet and several Kaggle garbage classification datasets exist; check licences.
  • Road damage or pothole detection: classify road photos as damaged or not, a strong civic-tech angle for Indian cities.
  • Indian food recognition: collect or find images of dishes such as idli, dosa and poha and classify them.
  • Medical images: chest X-ray datasets exist on Kaggle, but present the result as a learning exercise, never as a diagnostic tool.

Upgrades for final-year depth: compare ResNet18 with EfficientNet or a vision transformer, add Grad-CAM heatmaps to show where the model looks, export the model for a mobile app, or collect 100 of your own field photos as a real-world test set.

How do you explain an image classification project in a viva?

Open with: "I used transfer learning on a ResNet18 pretrained on ImageNet, trained a new classification layer and then fine-tuned the last block on my dataset, and evaluated it with per-class precision, recall and a confusion matrix on a held-out validation split."

Be ready for:

  • Why transfer learning instead of a CNN from scratch? (Small dataset, less compute, better features.)
  • What does freezing layers do?
  • Why normalise with ImageNet mean and standard deviation?
  • What is data augmentation, and why only on training images?
  • What does an epoch mean, and how did you decide how many to run?
  • Which classes does your model confuse, and why?

Our machine learning interview questions cover overfitting, validation and metrics with model answers.

Common mistakes in image classification projects

These five mistakes appear again and again:

  1. Augmenting the validation set. Random crops on validation images make your accuracy noisy and unfair. Augment training only.
  2. Data leakage between splits. Near-duplicate photos of the same leaf in train and validation inflate accuracy. Split by source where possible.
  3. Forgetting model.eval(). Batch normalisation behaves differently in training mode, which changes predictions.
  4. Wrong normalisation. Pretrained weights expect ImageNet mean and standard deviation. Skip it and accuracy drops for no visible reason.
  5. Claiming real-world accuracy from lab images. High scores on a clean dataset do not mean the model works on phone photos in a field. Test on your own photos and say what you found.

Avoid these and your results will survive tough questions.

Frequently asked questions

Which algorithm is best for an image classification project?

For student projects, a pretrained convolutional neural network such as ResNet18, ResNet50 or EfficientNet with transfer learning gives the best accuracy for the effort. Vision transformers can do better on larger datasets. Classical methods like SVM on hand-made features are mainly useful as a baseline to compare against.

Can I do an image classification project without a GPU?

Yes, for small datasets. Freezing the pretrained layers and training only the last layer runs on a CPU, just slowly. For anything larger, use Google Colab's free GPU tier, keeping in mind that Google does not guarantee free GPU availability, so save checkpoints to Drive.

How many images do I need for image classification?

With transfer learning, a few hundred images per class is usually enough for a solid student project, and even around a hundred per class can work for visually distinct classes. Keep classes reasonably balanced, and hold back at least 20 percent of images for validation.

Is image classification a good final-year project?

A basic classifier on a famous dataset is more of a mini project. For final year, add depth: a real-world test set you collected, a comparison of two or three architectures, Grad-CAM explanations, or a mobile or web demo. Clear numbers and honest limitations make the difference.

What is the difference between image classification and object detection?

Image classification gives one label for the whole image, such as "diseased leaf". Object detection finds and labels multiple objects with bounding boxes, such as every pothole in a road photo. Detection needs box-level annotations and models such as YOLO, so start with classification.

References