import argparse import os import sys import urllib.request from pathlib import Path from PIL import Image import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from transformers import AutoImageProcessor, AutoModelForImageClassification # List of public domain images for the 'OTHER' class (using Unsplash to avoid rate limits) OTHER_BIRD_URLS = [ f"https://images.unsplash.com/{photo_id}?w=500&auto=format&fit=crop&q=80" for photo_id in [ "photo-1452570053594-1b985d6ea890", "photo-1480044965905-02098d419e96", "photo-1516233758813-a38d024919c5", "photo-1551085254-e96b210db58a", "photo-1522441815192-d9f04eb0615c", "photo-1506220926022-cc5c12abdb35", "photo-1518998053901-5348d3961a04", "photo-1511823794984-b87716139b88", "photo-1470116890351-be0a9b418409", "photo-1539664030485-a936c7d29fc0", "photo-1444464666168-49d633b86797", "photo-1504386106331-3e4e71712b38", "photo-1555041469-a586c61ea9bc", "photo-1525462519782-b55cef2f882a", "photo-1509023467868-1c40786379f6", "photo-1454496522488-7a8e488e8606", "photo-1549488344-1f9b8d2bd1f3", "photo-1528183429752-a97d0bf99b5a", "photo-1510137600163-2729bc695ac1", "photo-1465153690352-10c1b295ec7e", "photo-1497250681960-ef046c08a56e", "photo-1526336024438-db9b601fc211", "photo-1534067783941-51c9c23eccfd", "photo-1560015534-eca11e59876a", "photo-1551806235-a05ff789c3c7", ] ] def download_image(url, filepath): """Downloads an image from a URL with a standard browser User-Agent header.""" req = urllib.request.Request( url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'} ) try: with urllib.request.urlopen(req, timeout=15) as response: with open(filepath, 'wb') as f: f.write(response.read()) # Verify it is a valid image with Image.open(filepath) as img: img.verify() return True except Exception as e: if os.path.exists(filepath): os.remove(filepath) print(f" [Warning] Failed to download or verify {url}: {e}") return False def setup_other_class(other_dir): """Sets up the OTHER class directory and downloads sample bird images.""" os.makedirs(other_dir, exist_ok=True) existing_files = [f for f in os.listdir(other_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] if len(existing_files) >= 10: print(f"-> 'OTHER' directory already has {len(existing_files)} images. Skipping download.") return print("-> Downloading background bird images for 'OTHER' class (to avoid forgetting)...") downloaded_count = 0 for i, url in enumerate(OTHER_BIRD_URLS): dest_path = os.path.join(other_dir, f"other_bird_{i}.jpg") print(f" Downloading image {i+1}/{len(OTHER_BIRD_URLS)}...") if download_image(url, dest_path): downloaded_count += 1 print(f"-> Completed downloading. Added {downloaded_count} images to 'OTHER' directory.") class BirdDataset(Dataset): def __init__(self, image_paths, labels, image_processor): self.image_paths = image_paths self.labels = labels self.image_processor = image_processor def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img_path = self.image_paths[idx] label = self.labels[idx] try: image = Image.open(img_path).convert("RGB") inputs = self.image_processor(images=image, return_tensors="pt") pixel_values = inputs["pixel_values"].squeeze(0) return pixel_values, torch.tensor(label, dtype=torch.long) except Exception as e: # Fallback for corrupt images during training # We return a dummy image (zeros) and the label print(f" [Warning] Error loading image {img_path}: {e}. Using zero-filled tensor.") dummy_pixel = torch.zeros((3, 224, 224)) return dummy_pixel, torch.tensor(label, dtype=torch.long) def train_model(args): dataset_path = Path(args.dataset_dir) hen_dir = dataset_path / "HEN" peacock_dir = dataset_path / "PEACOCK" other_dir = dataset_path / "OTHER" # 1. Verify dataset structure if not hen_dir.exists() or not peacock_dir.exists(): print("Error: Dataset directory must contain 'HEN' and 'PEACOCK' subdirectories.") print(f"Looked in: {args.dataset_dir}") sys.exit(1) # Count source images hen_imgs = list(hen_dir.glob("*.[jJ][pP][gG]")) + list(hen_dir.glob("*.[jJ][pP][eE][gG]")) + list(hen_dir.glob("*.[pP][nN][gG]")) peacock_imgs = list(peacock_dir.glob("*.[jJ][pP][gG]")) + list(peacock_dir.glob("*.[jJ][pP][eE][gG]")) + list(peacock_dir.glob("*.[pP][nN][gG]")) print(f"Found {len(hen_imgs)} images in HEN/") print(f"Found {len(peacock_imgs)} images in PEACOCK/") if len(hen_imgs) == 0 or len(peacock_imgs) == 0: print("Error: Both HEN and PEACOCK folders must contain at least a few images to train.") sys.exit(1) # 2. Setup OTHER class automatically setup_other_class(str(other_dir)) other_imgs = list(other_dir.glob("*.[jJ][pP][gG]")) + list(other_dir.glob("*.[jJ][pP][eE][gG]")) + list(other_dir.glob("*.[pP][nN][gG]")) # 3. Gather paths and labels class_names = ["HEN", "OTHER", "PEACOCK"] # Alphabetical order class_to_idx = {name: i for i, name in enumerate(class_names)} image_paths = [] labels = [] for name in class_names: dir_path = dataset_path / name imgs = list(dir_path.glob("*.[jJ][pP][gG]")) + list(dir_path.glob("*.[jJ][pP][eE][gG]")) + list(dir_path.glob("*.[pP][nN][gG]")) + list(dir_path.glob("*.[wW][eE][bB][pP]")) for img in imgs: # Simple pre-check to make sure it loads try: with Image.open(img) as temp_img: temp_img.draft("RGB", (32, 32)) image_paths.append(str(img)) labels.append(class_to_idx[name]) except Exception: print(f" [Warning] Skipping corrupt file: {img}") print(f"Total valid training samples: {len(image_paths)}") for name in class_names: idx = class_to_idx[name] count = labels.count(idx) print(f" Class '{name}': {count} images") # 4. Load online pretrained model and processor print("-> Loading pre-trained base model and image processor...") image_processor = AutoImageProcessor.from_pretrained("chriamue/bird-species-classifier") model = AutoModelForImageClassification.from_pretrained("chriamue/bird-species-classifier") # 5. Freeze base layers to make training super fast and prevent catastrophic overfitting print("-> Freezing feature extractor base weights (training classification head only)...") for param in model.parameters(): param.requires_grad = False # 6. Replace classification head # The original was: (classifier): Linear(in_features=1408, out_features=525, bias=True) num_features = model.classifier.in_features model.classifier = nn.Linear(num_features, len(class_names)) # Configure model config metadata so Hugging Face saves labels correctly model.config.id2label = {i: name for i, name in enumerate(class_names)} model.config.label2id = {name: i for i, name in enumerate(class_names)} model.config.num_labels = len(class_names) # 7. Create DataLoader dataset = BirdDataset(image_paths, labels, image_processor) dataloader = DataLoader(dataset, batch_size=args.batch_size, shuffle=True) # 8. Setup optimizer and loss optimizer = torch.optim.AdamW(model.classifier.parameters(), lr=args.lr) criterion = nn.CrossEntropyLoss() # 9. Training Loop device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) model.train() print(f"-> Starting training on device: {device}...") for epoch in range(args.epochs): running_loss = 0.0 correct = 0 total = 0 for batch_x, batch_y in dataloader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() outputs = model(pixel_values=batch_x) logits = outputs.logits loss = criterion(logits, batch_y) loss.backward() optimizer.step() running_loss += loss.item() * batch_x.size(0) _, predicted = torch.max(logits, 1) total += batch_y.size(0) correct += (predicted == batch_y).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total print(f" Epoch {epoch+1}/{args.epochs} - Loss: {epoch_loss:.4f} - Accuracy: {epoch_acc:.4f}") # 10. Save fine-tuned model print(f"-> Saving fine-tuned model to {args.output_dir}...") os.makedirs(args.output_dir, exist_ok=True) model.save_pretrained(args.output_dir) image_processor.save_pretrained(args.output_dir) print("-> Done! Custom model training completed successfully.") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Fine-tune bird species classifier on custom dataset.") parser.add_argument("--dataset_dir", type=str, default="dataset", help="Directory containing HEN and PEACOCK folders") parser.add_argument("--output_dir", type=str, default="custom_model", help="Directory to save custom model weights") parser.add_argument("--epochs", type=int, default=8, help="Number of training epochs") parser.add_argument("--batch_size", type=int, default=8, help="DataLoader batch size") parser.add_argument("--lr", type=float, default=1e-3, help="Learning rate for the classification head") args = parser.parse_args() train_model(args)