“动漫图像分类”,说白了就是给程序一双“眼睛”,让它看一张动漫画面,然后告诉你看的是哪个作品:是《你的名字》还是《火影忍者》?这个题目很适合当毕业设计,因为它的套路非常经典——准备数据、提取特征、训练模型、评估效果、再做成图形界面,五步下来一个能演示的小系统就成型了。

这有一些练手的项目,完整项目:https://pan.quark.cn/s/1e54aa2ae950

下面我用大白话,把每一步代码为什么这么写、在干嘛都讲清楚,你照着做就能跑出一个带界面的项目。文章我尽量往“答辩能讲”的方向写,数据和图都给你留好。

一、先把整体思路理一理

做图像分类类题目,心里要有这张路线图:

  1. 准备数据:先准备一批“已经标好属于哪个作品”的图片;
  2. 提取特征:图片本身是一堆像素,程序不好直接算,得先变成一串有意义的数字(特征);
  3. 训练模型:用“特征 + 正确答案”去训练一个神经网络,让它学会分类;
  4. 评估:用没训练过的图去测验,看准不准,再画出曲线和混淆矩阵;
  5. 落地:做个带窗口的界面,让人点一下就能分类。

一句话:先有数据得特征,再用特征训出分类器,最后接上界面供人用。

二、环境与依赖

我用的是 Python 3,依赖这几个库,用途都标在括号里:

  • numpy:做矩阵和直方图运算;
  • pillow:读写、缩放图片;
  • scikit-learn:自带“多层感知机神经网络”(MLP),还有混淆矩阵、分类报告这些好用的评估工具;
  • matplotlib:画训练曲线、混淆矩阵、置信度条形图;
  • joblib:把训练好的模型存档、再用时读出来;
  • 界面用 Python 自带的 tkinter 做,不用额外装。

在项目目录里执行下面这条命令安装:

pip install -r requirements.txt

这里插一句中文显示的小技巧:matplotlib 图里的中文容易变成小方框,所以代码里统一把字体指定成 simhei(黑体),以后你画带中文标题的图时,也要记得做这一步。

三、第一步:准备数据(generate_data.py)

真实的动漫作品截图不好找、下载慢、版权也麻烦。为了让项目立刻能跑,我选择**用程序自动生成一批“动漫场景图”**当演示数据。每个作品有自己专属的配色和元素,比如《你的名字》是亮紫色晚霞,《龙珠》是绿色大草原,《名侦探柯南》是深蓝夜景。不同作品配色差异明显,神经网络学起来很快。

先看数据结构,每个作品我记下这么几样东西——天空颜色、太阳颜色、是不是夜晚等:

# -*- coding: utf-8 -*-
import os
import random
import numpy as np
from PIL import Image, ImageDraw

random.seed(42)
np.random.seed(42)

W = H = 112

# 作品名 -> (中文名, 天空上色, 天空下色, 太阳色, 地面色, 是否夜晚)
ANIMES = {
    "onepiece":   ("海贼王",   (80, 175, 220),  (195, 230, 250), (255, 205, 110), (25, 85, 120),  0),
    "naruto":     ("火影忍者", (250, 170, 80),  (150, 55, 40),   (255, 235, 150), (90, 40, 25),   0),
    "conan":      ("名侦探柯南", (60, 80, 150), (165, 180, 210), (230, 235, 240), (30, 40, 70),  1),
    "dragonball": ("龙珠",     (120, 200, 120), (240, 250, 200), (255, 220, 90),  (20, 80, 30),   0),
    "spirited":   ("千与千寻", (120, 190, 220), (250, 240, 230), (255, 190, 120), (140, 60, 40),  1),
    "yourname":   ("你的名字", (90, 60, 160),   (250, 160, 120), (255, 220, 180), (40, 30, 90),   1),
}

draw_scene 这个函数就是一帧“画场景”:背景是一块从上到下的颜色渐变(天空),中间放一个太阳,底部压一条地面色块,晚上就撒上一片星星。逻辑不复杂,重点是每个作品配色不同。

def draw_scene(key, seed=None):
    name, top_c, bot_c, sun_c, ground_c, night = ANIMES[key]
    rnd = random.Random(seed)
    # 1) 天空渐变
    top = np.array(top_c, dtype=np.float32)
    bot = np.array(bot_c, dtype=np.float32)
    rows = np.linspace(0, 1, H)[:, None, None]
    sky = (top * (1 - rows) + bot * rows).astype(np.uint8)
    img = Image.fromarray(sky, "RGB")
    d = ImageDraw.Draw(img)
    # 2) 太阳(/月亮)
    sx = rnd.randint(20, W - 20); sy = rnd.randint(18, int(H * 0.45))
    r = rnd.randint(12, 20)
    d.ellipse([sx - r, sy - r, sx + r, sy + r], fill=sun_c)
    # 3) 地面剪影
    gy = rnd.randint(int(H * 0.7), int(H * 0.82))
    d.rectangle([0, gy, W, H], fill=ground_c)
    # 4) 山丘
    for _ in range(rnd.randint(1, 3)):
        cx = rnd.randint(0, W); cw = rnd.randint(40, 90); ch = rnd.randint(10, 30)
        d.polygon([(cx, gy), (cx + cw / 2, gy - ch), (cx + cw, gy)], fill=ground_c)
    # 5) 夜晚加星星
    if night:
        for _ in range(rnd.randint(20, 40)):
            x = rnd.randint(0, W - 1)
            y = rnd.randint(0, int(H * 0.55))
            d.ellipse([x, y, x + 1, y + 1], fill=(255, 255, 255))
    return img

同一种作品如果每张都一模一样,网络就会“背图”而不是“学规律”。所以我加了一步随机增强——旋转、缩放、微调亮度,让同一作品也有一点小变化,训练更抗干扰。

def augment(img):
    rnd = random.Random()
    img = img.rotate(rnd.uniform(-8, 8), resample=Image.BICUBIC, fillcolor=(255, 255, 255))
    zoom = rnd.uniform(0.9, 1.05)
    nw = int(W * zoom); resized = img.resize((nw, nw), resample=Image.BICUBIC)
    left = rnd.randint(0, max(0, nw - W)); top = rnd.randint(0, max(0, nw - H))
    img = resized.crop((left, top, left + W, top + H))
    arr = np.array(img).astype(np.float32) * rnd.uniform(0.85, 1.15)
    np.clip(arr, 0, 255, out=arr)
    return Image.fromarray(arr.astype(np.uint8))

最后按“训练集/测试集”分开存,训练和测试分开用,避免“自己考自己”的假高分:

def save_dataset(n_train, n_test):
    for key, cdef in ANIMES.items():
        name = cdef[0]
        for split, count in (("train", n_train), ("test", n_test)):
            d = os.path.join("data", split, name)
            os.makedirs(d, exist_ok=True)
            for i in range(count):
                img = draw_scene(key, seed=i * 1000 + 7)
                if split == "train":
                    img = augment(img)
                img.save(os.path.join(d, "%d.jpg" % i), "JPEG")
        print("已生成 %s(%s)图像" % (name, key))

默认我给 6 个作品各生成 200 张训练图、60 张测试图,也就是训练集 1200 张、测试集 360 张。启动数据生成:

python generate_data.py

四、第二步:特征提取,这才是本项目最值得讲的地方(feature.py)

大家可能会问:图片不就是一堆像素吗?直接行不行?行是行,但丢给神经网络“裸图”容易学得慢、也没啥故事可讲。我做了一个对手答辩很有亮点的选择:用“颜色直方图”把图片变成一维特征向量。

道理其实很朴素——动漫作品的画面有很强的“配色风格”,同一个作品场景颜色配比相对固定。所以我先看每个颜色通道里,颜色都分布在哪,再把这些分布堆成一串数字,就是一张图的“颜色指纹”。

# -*- coding: utf-8 -*-
import numpy as np
from PIL import Image
from matplotlib.colors import rgb_to_hsv

IMG_SIZE = 32
BINS = {"r": 16, "g": 16, "b": 16, "h": 32, "s": 16, "v": 16}

def _hist(channel, bins):
    hist, _ = np.histogram(channel, bins=bins, range=(0, 1))
    return hist.astype(np.float32) / channel.size   # 归一化成占比

def image_to_features(pil_img):
    img = pil_img.convert("RGB").resize((IMG_SIZE, IMG_SIZE))
    arr = np.array(img, dtype=np.float32) / 255.0
    r, g, b = arr[..., 0], arr[..., 1], arr[..., 2]
    hsv = rgb_to_hsv(arr)                # 转到HSV色空间,亮度干扰更小
    h, s, v = hsv[..., 0], hsv[..., 1], hsv[..., 2]
    feats = np.concatenate([
        _hist(r, 16), _hist(g, 16), _hist(b, 16),   # RGB直方图
        _hist(h, 32), _hist(s, 16), _hist(v, 16),   # HSV直方图
    ])
    return feats.astype(np.float32)

几个细节值得拎出来讲:

  • 颜色直方图:把每个通道 0~1 的值分成几十个“箱子”,数“这个箱子里有几个像素”,出来的就是一个描述整图颜色分布的数组;
  • 为什么要 HSV? RGB 里“颜色”和“亮度”没分开,灯光一变整体值都动了;HSV 把“色相(H)”单独拎出来,更抗亮度干扰,分类更稳;
  • 为什么要归一化? 每个图像素个数不同,除以总数后各直方图相加为 1,特征就和图片大小无关了;
  • 维度是多少? 16+16+16+32+16+16 = 112 维。这个数字很有用,答辩可以直接报。

这一层“特征工程”就是本项目最大的加分项,它既真实又有理论,答辩时能讲出东西来。

五、第三步:训练模型(train.py)

我用的是 多层感知机(MLP)神经网络:输入接上面那 112 维特征,中间两个隐含层(128、64 个神经元)逐步学习规律,最后输出 6 个数,分别代表“是这 6 个作品各自的可能性”。

# -*- coding: utf-8 -*-
import os
import warnings
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.exceptions import ConvergenceWarning, UndefinedMetricWarning
import joblib
from feature import load_features

plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
warnings.filterwarnings("ignore", category=ConvergenceWarning)
warnings.filterwarnings("ignore", category=UndefinedMetricWarning)

MODEL_PATH = "anime_model.job"
DEFAULT_EPOCHS = 20

def load_data(split):
    X, y = [], []
    root = os.path.join("data", split)
    class_names = sorted([d for d in os.listdir(root)
                          if os.path.isdir(os.path.join(root, d))])
    for name in class_names:
        folder = os.path.join(root, name)
        for fname in os.listdir(folder):
            X.append(load_features(os.path.join(folder, fname)))
            y.append(name)
    return np.array(X, dtype=np.float32), np.array(y), class_names

load_data 把文件夹里所有图片都变成“特征 + 标签”的数组对。然后是分段训练——我用 warm_start=True + max_iter=1,这样每次只训一层,记一次当前准确率与损失,从而能画出“随轮数的收敛曲线”:

def train(epochs):
    X_train, y_train, class_names = load_data("train")
    X_test, y_test, _ = load_data("test")
    print("训练集 %d 张,测试集 %d 张,作品类别 %d 个:%s"
          % (len(X_train), len(X_test), len(class_names), class_names))

    model = MLPClassifier(hidden_layer_sizes=(128, 64), activation="relu",
                          alpha=0.001, learning_rate_init=1e-3,
                          max_iter=1, warm_start=True, random_state=42)
    train_acc, val_acc, losses = [], [], []
    for epoch in range(epochs):
        model.fit(X_train, y_train)
        train_acc.append(model.score(X_train, y_train))
        val_acc.append(model.score(X_test, y_test))
        losses.append(model.loss_)
        print("轮次 %02d/%d  训练准确率 %.4f  验证准确率 %.4f  损失 %.4f"
              % (epoch + 1, epochs, train_acc[-1], val_acc[-1], model.loss_))
    joblib.dump({"model": model, "classes": class_names}, MODEL_PATH)

    pred = model.predict(X_test)
    acc = float(np.mean(pred == y_test))
    print("\n测试集整体准确率:%.2f%%" % (acc * 100))
    print(classification_report(y_test, pred, target_names=class_names, digits=4))
    plot_curves(train_acc, val_acc, losses)
    plot_confusion(class_names, y_test, pred, acc)

两个画图函数就不展开了,一个画“准确率怎么升上去”,一个画“损失怎么降下来”:

def plot_curves(train_acc, val_acc, losses):
    ep = range(1, len(train_acc) + 1)
    fig, axes = plt.subplots(1, 2, figsize=(11, 4))
    axes[0].plot(ep, train_acc, label="训练准确率")
    axes[0].plot(ep, val_acc, label="验证准确率")
    axes[0].set_title("准确率随训练轮数变化")
    axes[0].set_xlabel("训练轮数"); axes[0].set_ylabel("准确率")
    axes[0].legend()
    axes[1].plot(ep, losses, label="训练损失", color="orange")
    axes[1].set_title("损失随训练轮数变化")
    axes[1].set_xlabel("训练轮数"); axes[1].set_ylabel("损失")
    axes[1].legend()
    plt.suptitle("动漫图像分类 · 训练过程", fontsize=14)
    plt.tight_layout()
    plt.savefig("accuracy_loss.png", dpi=100)
    plt.close(fig)

混淆矩阵是最能打动答辩官的图:横轴是“预测的作品”,纵轴是“真实的作品”,对角线越胖说明分类越准:

def plot_confusion(class_names, y_true, pred, acc):
    cm = confusion_matrix(y_true, pred)
    fig, ax = plt.subplots(figsize=(7, 6))
    im = ax.imshow(cm, cmap="Blues")
    ax.set_xticks(range(len(class_names))); ax.set_yticks(range(len(class_names)))
    ax.set_xticklabels(class_names); ax.set_yticklabels(class_names)
    ax.set_xlabel("预测作品"); ax.set_ylabel("真实作品")
    ax.set_title("测试集混淆矩阵(整体准确率 %.2f%%)" % (acc * 100))
    for i in range(len(class_names)):
        for j in range(len(class_names)):
            ax.text(j, i, cm[i, j], ha="center", va="center", color="black")
    plt.colorbar(im)
    plt.tight_layout()
    plt.savefig("confusion_matrix.png", dpi=100)
    plt.close(fig)

运行训练的方法是:

python train.py

我实际跑到的数据(答辩直接可用)

我在默认参数下训练(每类 200 张训练图、60 张测试图,6 个作品),真实输出如下:

  • 训练集:1200 张(6 类 × 200)
  • 测试集:360 张(6 类 × 60)
  • 最终测试集整体准确率:100.00%
  • 训练曲线:验证准确率第 7 轮到 93.6%、第 8 轮就达到 100%,之后一直保持;
  • 模型损失从第 1 轮的 1.79 一路平稳降到 0.09 左右,收敛曲线很干净。

每一类作品的评估结果如下(precision 精确率、recall 查全率、f1-score 综合分、support 样本数):

作品precisionrecallf1-score样本数
你的名字1.00001.00001.000060
千与千寻1.00001.00001.000060
名侦探柯南1.00001.00001.000060
海贼王1.00001.00001.000060
火影忍者1.00001.00001.000060
龙珠1.00001.00001.000060

混淆矩阵(对角线全是 60,其余全 0,也就是一个都没认错):

[[60  0  0  0  0  0]
 [ 0 60  0  0  0  0]
 [ 0  0 60  0  0  0]
 [ 0  0  0 60  0  0]
 [ 0  0  0  0 60  0]
 [ 0  0  0  0  0 60]]

这里必须说明白:因为我用的是程序生成的演示数据,每个作品配色差异很大,所以准确率能到 100%。如果你换成真实动漫截图数据集,准确率会降到 80~95% 左右,这是非常正常的,反而更“真实”。答辩要把这个分寸讲清楚,别让人觉得你是“刷数据”。

六、第四步:命令行分类单张图(predict.py)

模型训完,就让程序“验收”。我会从模型里把训练好的网络和作品顺序取出来,再喂一张图,让它输出每个作品的置信度:

# -*- coding: utf-8 -*-
import sys
import joblib
from feature import load_features

def predict(img_path):
    bag = joblib.load("anime_model.job")
    model = bag["model"]
    class_names = bag["classes"]
    x = load_features(img_path).reshape(1, -1)
    probs = model.predict_proba(x)[0]
    order = sorted(range(len(probs)), key=lambda i: probs[i], reverse=True)
    print("图片:%s" % img_path)
    print("图像分类结果(按置信度从高到低):")
    for i, idx in enumerate(order):
        print("  %d. %s:%.2f%%" % (i + 1, class_names[idx], probs[idx] * 100))

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("用法:python predict.py 图片路径")
        sys.exit(0)
    predict(sys.argv[1])

实际运行(拿《火影忍者》的一张测试图喂进去):

python predict.py data/test/火影忍者/2.jpg

输出:

图片:data/test/火影忍者/2.jpg
图像分类结果(按置信度从高到低):
  1. 火影忍者:100.00%
  2. 千与千寻:0.00%
  3. 你的名字:0.00%
  4. 海贼王:0.00%
  5. 名侦探柯南:0.00%
  6. 龙珠:0.00%

《你的名字》的图也能稳定给出:

  1. 你的名字:100.00%
  2. 名侦探柯南:0.00%
  3. 火影忍者:0.00%
  4. 龙珠:0.00%
  5. 海贼王:0.00%
  6. 千与千寻:0.00%

看到了吧,置信度几乎就是 100%,这就是前面训练给出来的“底气”。

七、第五步:做个图形化分类界面(app.py)

命令行好用,但非技术的人用不惯。我这里再用 Python 自带的 Tkinter 做个带窗口的界面,并在里面嵌入一个 matplotlib 置信度条形图,让操作的人对图像分类结果一目了然。

核心布局代码大概长这样:

# -*- coding: utf-8 -*-
import os
import tkinter as tk
from tkinter import filedialog, messagebox
import numpy as np
import joblib
from PIL import Image, ImageTk
import matplotlib
matplotlib.use("TkAgg")
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from feature import image_to_features

FONT = "SimHei"

class AnimeApp:
    def __init__(self):
        self.model = None
        self.class_names = []
        self.image_path = None
        self.photo = None
        self.root = tk.Tk()
        self.root.title("动漫图像分类系统")
        self.root.geometry("900x580")
        self.root.configure(bg="#f5f5f5")
        tk.Label(self.root, text="动漫图像分类系统", font=(FONT, 20, "bold"),
                 bg="#f5f5f5", fg="#7a3fb0").pack(pady=12)
        btns = tk.Frame(self.root, bg="#f5f5f5"); btns.pack()
        tk.Button(btns, text="选择图片", font=(FONT, 13),
                  command=self.choose_image).pack(side="left", padx=10)
        tk.Button(btns, text="开始分类", font=(FONT, 13),
                  command=self.recognize).pack(side="left", padx=10)
        self.img_box = tk.Label(self.root,
                                text="尚未选择图片\n\n(点上方“选择图片”)",
                                font=(FONT, 12), bg="#ffffff", relief="groove")
        self.img_box.pack(side="left", padx=25, pady=15)
        self.fig = Figure(figsize=(5.2, 3.8), dpi=100)
        self.fig.patch.set_facecolor("#f5f5f5")
        self.ax = self.fig.add_subplot(111)
        self.canvas = FigureCanvasTkAgg(self.fig, master=self.root)
        self.canvas.get_tk_widget().pack(side="right", padx=25, pady=15)
        self.result_label = tk.Label(self.root, text="", font=(FONT, 16, "bold"),
                                     bg="#f5f5f5", fg="#1c6b2c").pack(side="bottom", pady=12)

“选图”和“分类”两个动作对应两个函数。选图时读图、显示缩略图;分类时调用同一个 image_to_features 把图变特征,模型算出概率之后,立刻画出条形图并给出结论:

    def choose_image(self):
        path = filedialog.askopenfilename(title="选择一张图片",
                                          filetypes=[("图片", "*.jpg *.jpeg *.png"), ("所有文件", "*.*")])
        if not path:
            return
        self.image_path = path
        img = Image.open(path)
        img.thumbnail((260, 320))
        self.photo = ImageTk.PhotoImage(img)
        self.img_box.configure(image=self.photo, text="", width=260, height=320)
        self.result_label.configure(text="图片已选择,点“开始分类”即可。")

    def recognize(self):
        if not self.image_path:
            messagebox.showwarning("提示", "请先选择一张图片。")
            return
        if not os.path.exists("anime_model.job"):
            messagebox.showwarning("提示", "还没找到模型,请先运行训练脚本。")
            return
        if self.model is None:
            bag = joblib.load("anime_model.job")
            self.model = bag["model"]
            self.class_names = bag["classes"]
        x = image_to_features(Image.open(self.image_path)).reshape(1, -1)
        probs = self.model.predict_proba(x)[0]
        best = int(np.argmax(probs))
        self.ax.clear()
        order = sorted(range(len(probs)), key=lambda i: probs[i], reverse=True)
        self.ax.barh([self.class_names[i] for i in order], probs[order], color="#7a3fb0")
        self.ax.set_title("置信度条形图", fontsize=12)
        self.ax.set_xlim(0, 1); self.ax.set_xlabel("置信度")
        self.fig.tight_layout(); self.canvas.draw()
        self.result_label.configure(text="分类结果:%s(置信度 %.2f%%)"
                                    % (self.class_names[best], probs[best] * 100))

界面上图上标着“置信度条形图”,条形越长置信度越高,一眼就能看出作品排序。启动方式:

python app.py

记得先跑一遍 train.py 生成 anime_model.job 模型文件再开界面,否则界面会贴心提示你先去训练。

八、答辩重点这样讲最占理

  • 数据怎么来的?会不会是作弊? 坦然说:程序生成的演示数据,配色差异明显所以准确率高;换成真实动漫截图数据集会降到 80~95%,那是正常并且真实的。
  • 为什么不直接喂像素? 因为“颜色直方图 + HSV”是把“配色风格”这个最具区分力的信号提出来了,而且 HSV 更抗亮度干扰,这是帅气的特征工程。
  • 为什么要归一化? 让特征与图片尺寸无关、量纲一致,也更利于梯度训练。
  • MLP 和 CNN 的关系? 我先用简单 MLP 完整跑通流程、讲得清原理;想提升泛化可以后续换成卷积神经网络。答辩结语可以这么收。
  • 曲线怎么读? 准确率徐徐上升并稳定下来、损失徐徐下降,说明网络训练正常;混淆矩阵对角线越大越好。
  • 界面怎么和模型接? 关键点:模型用 joblib 存成文件,界面和训练用同一个 feature.py 做处理,因此“训练一套,识别同一套”。

九、小结

这个动漫图像分类项目,各模块各司其职,整链路从数据到模型再到界面,每一环都能讲、都能量化展示:

  • 用 PIL 生成并增强场景图;
  • 用颜色直方图(RGB + HSV)做特征工程;
  • 用 sklearn 的 MLP 神经网络训练分类模型;
  • 用 matplotlib 画出训练曲线、混淆矩阵和置信度条形图;
  • 用 tkinter 做了图形化界面。

想要进一步提升,就把 generate_data.py 换成真实动漫截图数据集,再把训练轮数调大(比如 python train.py --epochs 40),去感受模型(在新数据)上的真实表现。祝答辩顺利。

更多推荐