用Pytorch实现猫狗二分类

14 阅读7分钟

用Pytorch实现猫狗二分类

预训练模型:resnet18 数据集:Kaggle 训练过程:

  • 准备数据:下载好Kaggle数据集并按照猫狗分类,可以使用脚本分类
  • 数据增强:变换出更多数据,防止过拟合
  • 加载预训练模型:加载 resnet18 的预训练权重
  • 改造模型:冻结所有层->换掉分类头(1000->2)
  • 训练
  • 验证
  • 保存
  • 制作用户界面并使用图片推理

下载的样本按照猫狗分类到两个文件夹里

把图片放到./data/all后运行脚本

import os
import shutil
import random

# ===== Config =====
SRC_DIR = "./data/all"       # 原始图片:cat.*.jpg, dog.*.jpg
DST_DIR = "./data"           # 输出:data/train, data/val
SPLIT_RATIO = 0.8            # 80% 训练,20% 验证
SEED = 42

# ===== Reproducibility =====
random.seed(SEED)

# ===== Split =====
for cls in ["cat", "dog"]:
    # 找所有该类的图
    files = [f for f in os.listdir(SRC_DIR) if f.startswith(cls + ".")]
    random.shuffle(files)

    split = int(len(files) * SPLIT_RATIO)
    train_files = files[:split]
    val_files = files[split:]

    # 建文件夹
    os.makedirs(f"{DST_DIR}/train/{cls}", exist_ok=True)
    os.makedirs(f"{DST_DIR}/val/{cls}", exist_ok=True)

    # 复制文件
    for f in train_files:
        shutil.copy(os.path.join(SRC_DIR, f),
                    os.path.join(DST_DIR, "train", cls, f))
    for f in val_files:
        shutil.copy(os.path.join(SRC_DIR, f),
                    os.path.join(DST_DIR, "val", cls, f))

    print(f"{cls}: train={len(train_files)}, val={len(val_files)}")

print("done!")

导入必要的库&设置常量

其中MEAN和STD这两个是大量图片色彩的均值与标准差,用于将图片色彩的值转换为较小的数,防止训练中梯度爆炸。

import os
os.environ['TORCH_HOME']='./cache'
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets,transforms,models

# constants
IMAGENET_MEAN=[0.485,0.456,0.406]
IMAGENET_STD=[0.229,0.224,0.225]

创建预处理流水线

在训练数据的预处理流水线中

  • 重新设置尺寸,缩小尺寸
  • 随机裁剪图像的一部分,使图片的尺寸符合模型的输入端,并使每一个batch的输入图像不同
  • 以百分之五十的概率水平翻转图像
  • 亮度,对比度,饱和度的随机化(0.2表示±0.2调整)
  • 将图片转化为tensor
  • 用平均值和标准差转换原图片,用于输入模型

在测试集的预处理流水线中,在以上的操作步骤中,去掉了颜色抖动和翻转两项,裁剪方式更改,其余处理相同

# Train transform: with augmentation 
train_transform = transforms.Compose([
    transforms.Resize(256),                       # resize short side to 256
    transforms.RandomCrop(224),                   # random crop to 224
    transforms.RandomHorizontalFlip(p=0.5),       # random horizontal flip
    transforms.ColorJitter(brightness=0.2,
                           contrast=0.2,
                           saturation=0.2),        # random color jitter
    transforms.ToTensor(),                        # to tensor, [0,1]
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

# Test transform: no augmentation
test_transform = transforms.Compose([
    transforms.Resize(256),                       # resize short side to 256
    transforms.CenterCrop(224),                   # center crop to 224
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])

创建数据集

训练数据从文件夹中获取,在属性中设置训练数据的路径和预处理流水线

# dataset
train_data = datasets.ImageFolder(
    root='./data/train',
    transform=train_transform
)
val_data = datasets.ImageFolder(
    root='./data/val',
    transform=test_transform
)

加载模型,替换分类头

加载预训练模型,先冻结预训练模型中的原有参数,再将最后一层的输出变成2个

# Model 
model = models.resnet18(weights=None)             # no download
model.load_state_dict(
    torch.load("./cache/hub/checkpoints/resnet18-f37072fd.pth",
               map_location='cpu')
)

for param in model.parameters():
    param.requires_grad = False                   # freeze all

model.fc = nn.Linear(512, 2)        # input of fc layer is 512

训练&保存

利用已经建立好的数据集加载数据,向前传播,反向传播,求导,求梯度,优化,循环数次后保存pth文件

if __name__ == '__main__':
    from torch.utils.data import DataLoader

    train_loader = DataLoader(train_data, batch_size=32, shuffle=True,
                          num_workers=2, pin_memory=True,
                          persistent_workers=True)
    val_loader = DataLoader(val_data, batch_size=32, shuffle=False,
                        num_workers=2, pin_memory=True,
                        persistent_workers=True)        # set to False if memory is tight

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

    criterion = nn.CrossEntropyLoss()
    optimizer = optim.SGD(
        filter(lambda p: p.requires_grad, model.parameters()),
        lr=0.001, momentum=0.9
    )

    for epoch in range(10):
        # Train
        model.train()
        running_loss = 0.0
        for imgs, labels in train_loader:
            imgs, labels = imgs.to(device), labels.to(device)

            optimizer.zero_grad()                 # clear gradients
            outputs = model(imgs)                 # forward
            loss = criterion(outputs, labels)     # compute loss
            loss.backward()                       # backward
            optimizer.step()                      # update
            running_loss += loss.item()

        print(f"Epoch {epoch+1}, Train Loss: {running_loss/len(train_loader):.4f}")

        # Validation
        model.eval()
        correct, total = 0, 0
        with torch.no_grad():
            for imgs, labels in val_loader:
                imgs, labels = imgs.to(device), labels.to(device)
                outputs = model(imgs)
                _, predicted = torch.max(outputs, 1)
                total += labels.size(0)
                correct += (predicted == labels).sum().item()

        print(f"Epoch {epoch+1}, Val Acc: {100 * correct / total:.2f}%")

    torch.save(model.state_dict(), "catdog_resnet18.pth")
    print("saved!")
Epoch 1, Train Loss: 0.1313
Epoch 1, Val Acc: 97.50%
Epoch 2, Train Loss: 0.0914
Epoch 2, Val Acc: 97.86%
Epoch 3, Train Loss: 0.0883
Epoch 3, Val Acc: 98.20%
Epoch 4, Train Loss: 0.0844
Epoch 4, Val Acc: 97.72%
Epoch 5, Train Loss: 0.0779
Epoch 5, Val Acc: 98.16%
Epoch 6, Train Loss: 0.0815
Epoch 6, Val Acc: 97.98%
Epoch 7, Train Loss: 0.0808
Epoch 7, Val Acc: 98.08%
Epoch 8, Train Loss: 0.0809
Epoch 8, Val Acc: 98.12%
Epoch 9, Train Loss: 0.0758
Epoch 9, Val Acc: 97.94%
Epoch 10, Train Loss: 0.0783
Epoch 10, Val Acc: 97.96%
saved!

可以尝试通过其他方法提高准确率

图形界面

插件:customtkinter

import os
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.models as models
from torchvision import transforms
from PIL import Image

import customtkinter as ctk
from tkinter import filedialog, messagebox


# ==================== Model ====================
# NOTE: must match training
def build_model():
    model = models.resnet18(weights=None)
    model.fc = nn.Linear(512, 2)
    return model


# ==================== Classes ====================
CLASS_NAMES = ["cat", "dog"]

DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = None
model_ready = False

# NOTE: must match test transform
IMAGENET_MEAN = [0.485, 0.456, 0.406]
IMAGENET_STD  = [0.229, 0.224, 0.225]

transform = transforms.Compose([
    transforms.Resize(256),
    transforms.CenterCrop(224),
    transforms.ToTensor(),
    transforms.Normalize(IMAGENET_MEAN, IMAGENET_STD),
])


def preprocess(pil_image):
    """RGB + resize 256 + center crop 224 + ToTensor + Normalize"""
    img = pil_image.convert("RGB")
    return transform(img).unsqueeze(0)


# ==================== Theme ====================
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

ACCENT = "#4da3ff"
CARD   = "#1a1a1a"
CARD_2 = "#222222"
TEXT   = "#ffffff"
SUB    = "#9a9a9a"
TRACK  = "#2a2a2a"

FONT       = ("Segoe UI", 12)
FONT_BOLD  = ("Segoe UI", 12, "bold")
FONT_TITLE = ("Segoe UI", 16, "bold")


# ==================== Animated bar ====================
class AnimatedBar(ctk.CTkProgressBar):
    def __init__(self, master, **kwargs):
        super().__init__(master, height=14,
                         progress_color=ACCENT,
                         fg_color=TRACK,
                         corner_radius=7, **kwargs)
        self.value = 0.0
        self.target = 0.0
        self._running = False
        self.set(0)

    def set_value(self, v):
        self.target = max(0.0, min(100.0, float(v))) / 100.0
        if not self._running:
            self._running = True
            self._animate()

    def _animate(self):
        diff = self.target - self.value
        if abs(diff) < 0.003:
            self.value = self.target
            self.set(self.value)
            self._running = False
            return
        self.value += diff * 0.25
        self.set(self.value)
        self.after(16, self._animate)


# ==================== App ====================
class App(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("Cats vs Dogs Classifier")
        self.geometry("1000x680")
        self.minsize(880, 600)

        self.grid_columnconfigure(0, weight=1)
        self.grid_columnconfigure(1, weight=1)
        self.grid_rowconfigure(1, weight=1)

        self.external_img = None
        self._preview_tk = None

        self._build_topbar()
        self._build_left()
        self._build_right()

    # -------------------------------------------------
    def _build_topbar(self):
        bar = ctk.CTkFrame(self, fg_color=CARD, corner_radius=12)
        bar.grid(row=0, column=0, columnspan=2, sticky="ew",
                 padx=16, pady=(16, 0))
        bar.grid_columnconfigure(1, weight=1)

        ctk.CTkLabel(bar, text="Model", font=FONT_BOLD,
                     text_color=SUB).grid(row=0, column=0, padx=(14, 8), pady=10)
        self.model_path_var = ctk.StringVar(value="No model selected")
        ctk.CTkEntry(bar, textvariable=self.model_path_var,
                     state="readonly").grid(row=0, column=1, sticky="ew",
                                            padx=(0, 8), pady=10)
        ctk.CTkButton(bar, text="Load .pth", width=110, command=self.load_model,
                      fg_color=ACCENT, text_color="#0d0d0d",
                      hover_color="#6fd0ff", corner_radius=8)\
            .grid(row=0, column=2, padx=(0, 14), pady=10)

    # -------------------------------------------------
    def _build_left(self):
        left = ctk.CTkFrame(self, fg_color=CARD, corner_radius=12)
        left.grid(row=1, column=0, sticky="nsew", padx=16, pady=16)
        left.grid_rowconfigure(1, weight=1)
        left.grid_columnconfigure(0, weight=1)

        ctk.CTkLabel(left, text="Image", font=FONT_TITLE,
                     text_color=TEXT).grid(row=0, column=0, sticky="w",
                                           padx=18, pady=(14, 8))

        # preview area
        self.preview_label = ctk.CTkLabel(left, text="(no image)",
                                          fg_color="#111111",
                                          corner_radius=8,
                                          text_color=SUB)
        self.preview_label.grid(row=1, column=0, sticky="nsew",
                                padx=18, pady=(0, 14))

        btn_row = ctk.CTkFrame(left, fg_color="transparent")
        btn_row.grid(row=2, column=0, sticky="ew", padx=18, pady=(0, 14))

        ctk.CTkButton(btn_row, text="Load Image", width=120,
                      command=self.load_image,
                      fg_color=CARD_2, text_color=TEXT,
                      hover_color="#2e2e2e", corner_radius=8)\
            .pack(side="left")
        ctk.CTkButton(btn_row, text="Recognize", width=120,
                      command=self.recognize,
                      fg_color=ACCENT, text_color="#0d0d0d",
                      hover_color="#6fd0ff", corner_radius=8)\
            .pack(side="right")

    # -------------------------------------------------
    def _build_right(self):
        right = ctk.CTkFrame(self, fg_color=CARD, corner_radius=12)
        right.grid(row=1, column=1, sticky="nsew", padx=(0, 16), pady=16)
        right.grid_columnconfigure(0, weight=1)
        right.grid_rowconfigure(3, weight=1)

        ctk.CTkLabel(right, text="Result", font=FONT_TITLE,
                     text_color=TEXT).grid(row=0, column=0, sticky="w",
                                           padx=18, pady=(14, 8))

        self.result_var = ctk.StringVar(
            value="Load an image, then click Recognize")
        self.result_label = ctk.CTkLabel(
            right, textvariable=self.result_var,
            font=FONT_BOLD, text_color=TEXT,
            fg_color="#111111", corner_radius=8,
            anchor="w", justify="left",
            padx=14, pady=16,
        )
        self.result_label.grid(row=1, column=0, sticky="ew", padx=18)

        ctk.CTkLabel(right, text="Probabilities", font=FONT_BOLD,
                     text_color=SUB).grid(row=2, column=0, sticky="w",
                                          padx=18, pady=(18, 8))

        prob = ctk.CTkFrame(right, fg_color="transparent")
        prob.grid(row=3, column=0, sticky="nsew", padx=18, pady=(0, 14))
        prob.grid_columnconfigure(1, weight=1)

        self.bars = []
        self.bar_labels = []
        for i, name in enumerate(CLASS_NAMES):
            ctk.CTkLabel(prob, text=name, font=FONT, text_color=TEXT,
                         width=90, anchor="w")\
                .grid(row=i, column=0, sticky="w", pady=3)
            bar = AnimatedBar(prob)
            bar.grid(row=i, column=1, sticky="ew", padx=10, pady=3)
            val = ctk.CTkLabel(prob, text="0%", font=FONT, text_color=SUB,
                               width=50, anchor="e")
            val.grid(row=i, column=2, sticky="e", pady=3)
            self.bars.append(bar)
            self.bar_labels.append(val)

    # -------------------------------------------------
    def load_model(self):
        path = filedialog.askopenfilename(
            title="Select .pth model",
            filetypes=[("PyTorch model", "*.pth"), ("All files", "*.*")])
        if not path:
            return
        global model, model_ready
        try:
            m = build_model().to(DEVICE)
            m.load_state_dict(torch.load(path, map_location=DEVICE))
            m.eval()
            model = m
            model_ready = True
            self.model_path_var.set(path)
        except Exception as e:
            messagebox.showerror("Load failed", f"Failed to load model:\n{e}")

    # -------------------------------------------------
    def load_image(self):
        path = filedialog.askopenfilename(
            title="Select image",
            filetypes=[("Image", "*.png *.jpg *.jpeg *.bmp"),
                       ("All files", "*.*")])
        if not path:
            return
        self.external_img = Image.open(path)
        # fit preview to current label size
        self.update_idletasks()
        w = max(self.preview_label.winfo_width(), 200)
        h = max(self.preview_label.winfo_height(), 200)
        preview = self.external_img.convert("RGB").copy()
        preview.thumbnail((w - 16, h - 16), Image.BILINEAR)
        self._preview_tk = ctk.CTkImage(light_image=preview,
                                        dark_image=preview,
                                        size=preview.size)
        self.preview_label.configure(image=self._preview_tk, text="")

    # -------------------------------------------------
    def recognize(self):
        if not model_ready:
            messagebox.showwarning("Warning", "Please load a .pth model first")
            return
        if self.external_img is None:
            messagebox.showwarning("Warning", "Please load an image first")
            return

        tensor = preprocess(self.external_img).to(DEVICE)
        with torch.no_grad():
            out = model(tensor)
            prob = F.softmax(out, dim=1)[0]
            pred = out.argmax(dim=1).item()
            conf = prob[pred].item()

        self.result_var.set(
            f"Prediction: {CLASS_NAMES[pred]}    Confidence: {conf*100:.1f}%"
        )
        for i in range(2):
            p = prob[i].item() * 100
            self.bars[i].set_value(p)
            self.bar_labels[i].configure(text=f"{p:.0f}%")


if __name__ == "__main__":
    App().mainloop()