Skip to main content

Project: Image Classifier with Local Fixtures

This project classifies horizontal versus vertical bars in tiny repository-owned PGM images. It exercises real image decoding without a dataset download, pretrained weights, accelerator, account, or secret. Its purpose is to verify the pipeline—not to claim production vision performance.

Prerequisites​

Complete the preceding computer-vision lessons, especially Images as Tensors, Image Augmentation, and Vision Evaluation and Class Imbalance. Use the pinned PyTorch 2.14.1, TorchVision 0.29.1, and Pillow 12.3.0 environment.

Learning goals​

  • load repository-owned RGB fixtures with explicit labels, range, channel order, and split directories.
  • fit normalization and choose a checkpoint using training/validation data only.
  • report confusion, per-class recall, macro recall, and named errors on the one-time test evaluation.

Worked example​

ImageFolder derives labels alphabetically from directory names: horizontal=0, vertical=1. Each split owns distinct files; no augmented copy crosses a boundary. The PGM decoder is converted to RGB, then TorchVision produces float (C, H, W) tensors in [0, 1] without resizing because every fixture is exactly 8-by-8.

import copy
import os
from pathlib import Path

import torch
from torch import nn
from torchvision.datasets import ImageFolder
from torchvision.transforms import v2

torch.manual_seed(44)
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
device = torch.device("cpu")
fixture_root = Path(os.environ.get("VISION_FIXTURE_ROOT", "tests/fixtures/modern-ai-vision"))
to_tensor = v2.Compose([v2.ToImage(), v2.ToDtype(torch.float32, scale=True)])

datasets = {
split: ImageFolder(fixture_root / split, transform=to_tensor)
for split in ("train", "validation", "test")
}
assert datasets["train"].class_to_idx == {"horizontal": 0, "vertical": 1}

def stack(split):
images, labels = zip(*(datasets[split][index] for index in range(len(datasets[split]))))
return torch.stack(images).to(device), torch.tensor(labels, dtype=torch.long, device=device)

train_images, train_labels = stack("train")
validation_images, validation_labels = stack("validation")
test_images, test_labels = stack("test")
channel_mean = train_images.mean(dim=(0, 2, 3), keepdim=True)
channel_std = train_images.std(dim=(0, 2, 3), keepdim=True).clamp_min(1e-6)

def normalize(images):
return (images - channel_mean) / channel_std

model = nn.Sequential(
nn.Conv2d(3, 8, kernel_size=3, padding=1),
nn.ReLU(),
nn.Conv2d(8, 8, kernel_size=3, padding=1),
nn.ReLU(),
nn.AdaptiveMaxPool2d((1, 1)),
nn.Flatten(),
nn.Linear(8, 2),
).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.03, weight_decay=0.01)
loss_function = nn.CrossEntropyLoss()

def evaluate(images, labels):
model.eval()
with torch.inference_mode():
logits = model(normalize(images))
return loss_function(logits, labels).item(), logits.argmax(dim=1)

best_loss, best_epoch, best_state = float("inf"), 0, None
for epoch in range(1, 81):
model.train()
optimizer.zero_grad(set_to_none=True)
loss = loss_function(model(normalize(train_images)), train_labels)
loss.backward()
optimizer.step()
validation_loss, _ = evaluate(validation_images, validation_labels)
if validation_loss < best_loss:
best_loss, best_epoch = validation_loss, epoch
best_state = copy.deepcopy(model.state_dict())

model.load_state_dict(best_state)
test_loss, test_predictions = evaluate(test_images, test_labels) # first and only test use
confusion = torch.zeros((2, 2), dtype=torch.int64)
for truth, predicted in zip(test_labels.cpu(), test_predictions.cpu()):
confusion[truth, predicted] += 1
recall = confusion.diag() / confusion.sum(dim=1)
accuracy = confusion.diag().sum() / confusion.sum()
errors = [Path(datasets["test"].samples[index][0]).name
for index, (truth, predicted) in enumerate(zip(test_labels, test_predictions)) if truth != predicted]

print(f"classes={datasets['train'].class_to_idx} splits={{'train': {len(train_labels)}, 'validation': {len(validation_labels)}, 'test': {len(test_labels)}}}")
print(f"train_batch={tuple(train_images.shape)} dtype={train_images.dtype} device={train_images.device} range=({train_images.min():.1f}, {train_images.max():.1f})")
print(f"selected_epoch={best_epoch} test_loss={test_loss:.4f}")
print(f"confusion={confusion.tolist()} accuracy={accuracy:.3f} macro_recall={recall.mean():.3f}")
print(f"per_class_recall={{'horizontal': {recall[0]:.3f}, 'vertical': {recall[1]:.3f}}} errors={errors}")

Output with the pinned CPU environment:

classes={'horizontal': 0, 'vertical': 1} splits={'train': 6, 'validation': 2, 'test': 4}
train_batch=(6, 3, 8, 8) dtype=torch.float32 device=cpu range=(0.0, 1.0)
selected_epoch=12 test_loss=0.0001
confusion=[[2, 0], [0, 2]] accuracy=1.000 macro_recall=1.000
per_class_recall={'horizontal': 1.000, 'vertical': 1.000} errors=[]

The validation split selects the checkpoint; the test split is touched only after that state is fixed. Empty errors means only that this deliberately easy fixture was separated correctly—not that error inspection can be skipped on realistic data.

Common mistakes​

  • Letting the same source image or an augmented derivative appear in multiple splits.
  • Computing normalization statistics over validation or test images.
  • Assuming folder names have the intended numeric label order without checking class_to_idx.
  • Reporting test accuracy alone, especially when class support is uneven.
  • Iterating on architecture, epoch count, or preprocessing after reading test errors.
  • Treating tiny, synthetic, balanced bars as evidence for natural-image shift, calibration, robustness, fairness, or latency.

Practice​

Add a diagonal-bar class using new files in every split. Before training, write the expected label mapping and output shape. Then decide how many independent source images—not crops of the same image—you would need for a meaningful evaluation.

Next steps​

The computer-vision batch is complete. Return to the Deep Learning & Modern AI overview; the next path begins with text data and tokenization.

Primary sources​

Checked 2026-10-16: TorchVision ImageFolder, TorchVision transforms v2 guide, PyTorch reproducibility, PyTorch CrossEntropyLoss, and Pillow PPM/PGM support.