Semantic segmentation of pets using a U-Net implemented from scratch in PyTorch.
Trained on the Oxford-IIIT Pet Dataset, achieving Validation Dice 0.80 on CPU.
This is my first deep learning segmentation project. I built a U-Net from scratch in PyTorch and trained it to segment cats and dogs from background pixels in the Oxford-IIIT Pet Dataset.
The project includes the full ML pipeline - data loading, augmentation, training, validation, prediction, and several model interpretability visualizations (feature maps, Grad-CAM, U-shape architecture diagram).
| Metric | Train | Validation |
|---|---|---|
| Dice Score | 0.8342 | 0.7993 |
| IoU Score | 0.7175 | 0.6681 |
| Loss | 0.3741 | 0.4399 (best) |
Trained for 50 epochs on CPU (~12 hours).
- U-Net from scratch: 3-level encoder-decoder with skip connections
- Combined loss: Dice Loss + 0.5 Γ BCE Loss for stable training under class imbalance
- Synced augmentation: Image and mask transformed together
- Metrics: Dice Score and IoU computed per epoch
- TensorBoard logging: Real-time training monitoring
- Model interpretability: Feature maps, Grad-CAM, U-shape architecture diagram
- Best model checkpointing: Saves the model with lowest validation loss
Input β Ground Truth β Model Prediction:
Raw model output - bright areas = high cat-probability:
8 of the 32 filters in enc1 - each detects different features (edges, textures, contrast):
Watching features get more abstract from shallow (top) to deep (bottom). Notice how the bottleneck compresses the cat into a small bright blob:
The classic "U-shape" - encoder shrinks (left), bottleneck at the bottom, decoder grows (right):
Shows where the model "looks" when making predictions - concentrated on the cat's body:
Validation Dice climbing steadily to ~0.80 over 50 epochs:
A real U-Net with 3 encoder levels, bottleneck, and 3 decoder levels with skip connections.
Encoder:
enc1: ConvBlock(3 β 32) β 128Γ128
enc2: ConvBlock(32 β 64) β 64Γ64
enc3: ConvBlock(64 β 128) β 32Γ32
Bottleneck:
ConvBlock(128 β 256) β 16Γ16
Decoder (with skip connections):
upconv3 + dec3(256 β 128) β 32Γ32
upconv2 + dec2(128 β 64) β 64Γ64
upconv1 + dec1(64 β 32) β 128Γ128
Output:
Conv2d(32 β 1, 1Γ1) β segmentation mask
Each ConvBlock is Conv β BatchNorm β ReLU repeated twice.
Oxford-IIIT Pet Dataset - 7,390 images of 37 cat and dog breeds with pixel-level segmentation masks.
The original masks have three classes:
- 1 = pet border/outline (~9.6% of pixels)
- 2 = background (~82.8% of pixels)
- 3 = pet body (~7.6% of pixels)
For binary segmentation, I treat border + body as foreground:
mask = ((mask == 1) | (mask == 3)).astype("float32")This gives ~17% foreground coverage - much easier to learn than body-only (7.6%) and produces clean filled cat silhouettes.
git clone https://github.com/saeed-moo/pet-image-segmentation.git
cd pet-image-segmentation
python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS/Linux
source .venv/bin/activate
pip install -r requirements.txtpython download_dataset.pypython check_masks.pyThis prints unique mask values and shows pet/background coverage - useful for verifying mask binarization.
python train.pyTrains for 50 epochs, saves the best model as model_best.pth, and logs metrics to TensorBoard.
Monitor in another terminal:
tensorboard --logdir=runspython compare_predictions.py # Input + GT + Prediction (3 panels)
python predict.py # Input + Prediction (2 panels)python visualise_features.py # Feature maps from enc1
python gradcam_visualise.py # Grad-CAM attention heatmapThis project taught me a lot about real-world ML debugging. Three big issues I had to track down:
Initially I used mask <= 2 thinking it kept the pet. But values 1 and 2 in this dataset are border + background, not pet. This inversion gave a misleading fake Dice 0.937 because the model just predicted "everything is foreground" and got 92% pixel accuracy.
I diagnosed it by inspecting unique mask values and visualizing what each binarization option actually selected:
Value 1: 9.56% (border)
Value 2: 82.83% (background β biggest!)
Value 3: 7.61% (pet body)
The visualization made it obvious - mask <= 2 was selecting border + background (everything except the cat body), not the pet. This is why class imbalance was so extreme and the model collapsed to predicting "all foreground."
My RandomHorizontalFlip only flipped the image, not the mask. So the model was being trained on image-mask pairs where the cat was in one position and the label said it was in another - essentially noise. Fixed by using torchvision.transforms.functional to apply the same transform to both.
With pet body at only 7.6% of pixels, plain BCE loss converged to "predict all background." Solved by combining Dice Loss (handles imbalance natively) with weighted BCE Loss for boundary detail.
Final result: Real Val Dice 0.80 (up from a broken fake 0.937 β real but mediocre 0.42 β genuinely good 0.80).
pet-image-segmentation/
βββ train.py # Training script
βββ predict.py # Single-image prediction
βββ compare_predictions.py # 3-panel comparison
βββ visualise_features.py # Feature map visualization
βββ gradcam_visualise.py # Grad-CAM heatmap
βββ check_masks.py # Mask inspection utility
βββ dataset.py # PyTorch Dataset
βββ metrics.py # Dice & IoU + DiceLoss class
βββ download_dataset.py # Dataset downloader
βββ requirements.txt # Python dependencies
βββ models/
β βββ unet.py # U-Net model
βββ data/ # Images and masks (not in repo)
βββ results/ # Visualizations (in repo)
βββ runs/ # TensorBoard logs (not in repo)
PyTorch β’ Torchvision β’ NumPy β’ PIL β’ OpenCV β’ Matplotlib β’ TensorBoard
- U-Net paper (Ronneberger et al., 2015)
- Grad-CAM paper (Selvaraju et al., 2016)
- Oxford-IIIT Pet Dataset
MIT License
β Star this repo if you found it useful!







