“动漫角色识别”一听就很有画面感,其实就是让程序学会“看脸认人”:给它一张动漫头像,它能告诉你这是小樱、初音还是路飞。这个题目拿来当毕业设计或者练手项目都很合适,因为它把一个比较完整的机器学习流程——准备数据、提取特征、训练模型、评估、再做成界面——全都串起来了。

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

下面我就用大白话,把这个项目从零到一整个讲清楚,你跟着一步一步做,最后会有一个能跑、能出图、还能操作的可视化界面。

一、先想清楚整体思路

做这类“识别”任务,脑子里先要有一张流程图。我们这个项目的处理路径是:

  1. 准备数据:先有足够多的、按照角色分好类的图片;
  2. 提取特征:把每张图片变成一串数字(特征向量),让程序能够计算;
  3. 训练模型:用这些“数字”去训练一个神经网络,让它学会分辨不同角色;
  4. 评估效果:用没参与训练的图片去考,看它准不准,画出曲线和混淆矩阵;
  5. 落地识别:做个界面,让不认识的人也能一键测一张图。

一句话总结:先有数据,再变特征,然后学出一个分类器,最后把它接上界面供人使用。

二、用什么环境、装什么库

我用的是 Python 3,需要这几个库,功能在括号里标清楚了:

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

安装依赖这一步,在项目目录里输入下面的命令就行:

pip install -r requirements.txt

这里顺手提醒一下,代码里的中文显示都把字体指定成 simhei(黑体),这样在训练曲线、混淆矩阵的图上,标题和轴标签才不会变成一排小方框。你以后写自己的项目遇到图上中文乱码,多半也是字体没指定好的问题。

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

真实的动漫数据集不太好找,而且下载慢、版权也麻烦。为了让项目能立刻跑起来、又能做成答辩,我选择**用程序自己生成一批“简笔动漫脸”**当演示数据,每个角色用不同的头发颜色、眼睛颜色和一个小标志区分。这样数据量稳定、特征也清晰,训练起来效果直观。

来看看这段生成数据的代码:

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

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

IMG_SIZE = 112

# 角色标志 -> (名称, 头发颜色, 眼睛颜色, 前额装饰)
CHARACTERS = {
    "sakura":  ("小樱", (255, 160, 190), (200, 40, 90),  "star"),
    "sasuke":  ("佐助", (30, 30, 70),    (70, 70, 210),  "drop"),
    "luffy":   ("路飞", (170, 120, 50),  (90, 70, 40),   "circle"),
    "miku":    ("初音", (100, 210, 230), (50, 200, 230), "s"),
    "naruto":  ("鸣人", (250, 205, 60),  (80, 160, 220), "cap"),
    "chihiro": ("千寻", (125, 40, 150),  (120, 90, 60),  "heart"),
}

def draw_face(key, seed=None):
    hair_color, eye_color, mark = CHARACTERS[key][1], CHARACTERS[key][2], CHARACTERS[key][3]
    rnd = random.Random(seed)
    img = Image.new("RGB", (IMG_SIZE, IMG_SIZE), (250, 248, 245))
    d = ImageDraw.Draw(img)

    skin = (250, 236, 214)
    dark = (40, 40, 45)
    d.ellipse([16, 26, 96, 92], fill=hair_color)   # 后发
    d.ellipse([28, 44, 84, 96], fill=skin)          # 脸
    d.pieslice([18, 20, 96, 78], 180, 360, fill=hair_color)  # 刘海
    d.rectangle([18, 20, 96, 40], fill=hair_color)
    d.pieslice([18, 20, 52, 88], 110, 250, fill=hair_color)
    d.pieslice([64, 20, 98, 88], -70, 60, fill=hair_color)
    for ex in (36, 76):
        ey = int(62 + rnd.uniform(-2, 2))
        d.ellipse([ex, ey, ex + 10, ey + 14], fill=eye_color)
        d.ellipse([ex + 2, ey + 1, ex + 4, ey + 4], fill=(255, 255, 255))
    d.line([32, 84, 38, 86], fill=dark, width=2)
    # 前额标志,让每个角色更有个性
    if mark == "star":
        d.polygon([(25, 34), (29, 44), (18, 39), (32, 39), (21, 44)], fill=(255, 200, 60))
    elif mark == "drop":
        d.ellipse([26, 30, 34, 40], fill=(255, 120, 120))
    elif mark == "circle":
        d.ellipse([24, 28, 36, 40], outline=(255, 255, 255), width=2)
    elif mark == "cap":
        d.arc([22, 20, 60, 40], 100, 260, fill=(30, 30, 30), width=4)
    elif mark == "heart":
        d.polygon([(28, 34), (32, 40), (36, 34), (32, 44)], fill=(255, 120, 120))
    return img

draw_face 这个函数就是“画一个卡通头像”:先画头发打底,再画一张脸、刘海、眼睛、嘴巴,最后在前额处加上一个小标志。不同角色换成不同的颜色和标志,就出现了天然的类别差异,这正是模型能学会分类的“依据”。

为了让数据量足够大、又不会全都一模一样,我还会在上面的基础上做一轮随机增强——把图片随机旋转、缩放、改变亮度。这样同一个角色的不同图片各有变化,网络学会的是“角色特征”,而不是“记图”。

def augment(img):
    rnd = random.Random()
    img = img.rotate(rnd.uniform(-8, 8), resample=Image.BICUBIC, fillcolor=(250, 248, 245))
    zoom = rnd.uniform(0.9, 1.05)
    nw = int(IMG_SIZE * zoom)
    resized = img.resize((nw, nw), resample=Image.BICUBIC)
    left = rnd.randint(0, max(0, nw - IMG_SIZE))
    top = rnd.randint(0, max(0, nw - IMG_SIZE))
    img = resized.crop((left, top, left + IMG_SIZE, top + IMG_SIZE))
    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 CHARACTERS.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_face(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

IMG_SIZE = 32

def load_features(path):
    img = Image.open(path).convert("RGB").resize((IMG_SIZE, IMG_SIZE))
    arr = np.array(img, dtype=np.float32) / 255.0
    return arr.reshape(-1)

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
    return arr.reshape(-1)

这段代码做的事很朴素,但很关键:

  1. 打开图片,统一转成 RGB;
  2. 统一缩放到 32×32 这样的一个小尺寸——图片小了,训练的速度就比较快;
  3. 把像素值除以 255,缩放到 0~1 之间,网络训练才稳定;
  4. 把二维像素“拉”成一维向量,32×32×3 = 3072 个数字,就是这个特征向量。

我把这一步抽成 feature.py,让训练、命令行识别、图形界面三方都调用它。这样“训练时怎么处理,识别时也怎么处理”,输入一致,模型才不会到处判。

五、第三步:训练模型(train.py)——带数据看效果

下面是最核心的一步。我用来识别的是一个多层感知机(MLP)神经网络:输入层接收上面那 3072 维特征,经过两个隐藏层(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 把文件夹里的图片全部读进来,变成两个大数组:一个是特征矩阵 X,一个是对应的角色名 y,相当于把图片和正确答案一一对应。

然后开始“多轮地练”。我用的技巧是 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)

你可能会问:为什么不把 fit 一次调到底、不看它中间的收敛过程?因为答辩员喜欢看“准确率和损失是怎么一步一步变化的”。所以我人为分轮,每轮记录三个关键数字:训练准确率、验证准确率、损失

画曲线的两个函数很直观,一个画“准确率怎么升上去”,一个画“损失怎么降下来”:

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)

然后是混淆矩阵。混淆矩阵是个 6×6 的表格,横轴是“预测的角色”,纵轴是“真实角色”,对角线越胖越好。答辩的时候讲“我每个角色都几乎没认错”,用这张图最有说服力:

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%
  • 训练过程中验证准确率第 9 轮就已经到 100%,之后一直稳定在 100%
  • 训练损失从第 1 轮的 2.09 一路降到 0.02 附近,曲线非常典型

每一类角色的指标如下(precision 精确率、recall 查全率、f1-score 综合分、support 该类别样本数):

角色 precision recall f1-score 样本数
佐助 1.0000 1.0000 1.0000 60
初音 1.0000 1.0000 1.0000 60
千寻 1.0000 1.0000 1.0000 60
小樱 1.0000 1.0000 1.0000 60
路飞 1.0000 1.0000 1.0000 60
鸣人 1.0000 1.0000 1.0000 60

混淆矩阵(对角线全 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%),那是正常的,反而更显得“真实”。答辩的时候这种分寸要讲清楚:demo 高准确率靠的是数据可区分,真实场景有难度才是常态。

六、第四步:命令行识别单张图(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/小樱/0.jpg

实测输出如下(把一张小樱的测试图喂进去):

图片:data/test/小樱/0.jpg
角色识别结果(按置信度从高到低):
  1. 小樱:99.94%
  2. 初音:0.06%
  3. 千寻:0.00%
  4. 鸣人:0.00%
  5. 路飞:0.00%
  6. 佐助:0.00%

你看,程序非常硬地认出了小樱,置信度接近 100%。这就是前面积累的训练成果在“收获”了。

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

命令行好用,但对不懂技术的人不友好。所以我又做了带窗口的图形界面(用 Python 自带的 Tkinter 来画控件,再用 matplotlib 在里面嵌一个条形图)。界面功能是:

  1. 点“选择图片”,从电脑里选一张动漫头像;
  2. 左边把这张图放大显示;
  3. 点“开始识别”;
  4. 右边即时画出一个每个角色置信度的条形图
  5. 底部直接读出来识别结果。

核心代码长这样:

# -*- 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="#2a4d8f").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")
        self.result_label.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))

        order = sorted(range(len(probs)), key=lambda i: probs[i], reverse=True)
        self.ax.clear()
        self.ax.barh([self.class_names[i] for i in order], probs[order], color="#2a6db5")
        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 模型文件;如果还没有生成模型,界面会友好地提示你“先去训练”。

八、答辩时怎么讲这套项目更占理

答辩不只看“能不能跑”,关键在“你讲不讲得清”。给你思路:

  1. 数据从哪来?会不会作弊? —— 说明我用的是程序生成的演示数据,颜色区分明显,所以准确率高;换成真实数据集准确率会降到 80~95%,这是正常现象。
  2. 特征为什么先统一缩放到 32×32? —— 是为了速度,也为了让网络的输入维度固定;除以 255 是为了把像素值拉到 0~1,训练更稳定。
  3. 为什么用 MLP,不用卷积神经网络 CNN? —— 我先用最简单的 MLP 完整跑通流程,讲得清原理;多层卷积神经网络的实现更复杂、训练更久,但思想一脉相承:都是“特征 → 更深表示 → 分类”。你完全可以在总结里写“后续可升级为 CNN 提升泛化能力”。
  4. 混淆矩阵 / 损失曲线怎么解读? —— 横纵轴怎么对应、对角线越大越好、损失单调下降说明收敛正常。
  5. 界面怎么和模型接起来的? —— 重点在于模型被 joblib 存成文件,界面通过同一个 feature.py 统一处理输入,所以“训练一套、识别同一套”。

九、最后总结

这个动漫角色识别项目,麻雀虽小五脏俱全:

  • 用 PIL 生成并增强图片数据;
  • 用 scikit-learn 的多层感知机训练出可用的分类模型;
  • 用 matplotlib 可视化出训练曲线、混淆矩阵和置信度条形图;
  • 用 tkinter 做了一个有“图 + 数 + 结论”的图形化界面。

整个链路从数据到模型再到落地界面,每一步都能展开讲、都能量化展示,很适合在答辩现场现场演示。把这套思路写进你的毕设论文里,数据、图表、界面一应俱全,沟通起来就顺很多。

如果你想把准确率再拉起来、或者换更真实的角色数据集,方向也很明确:把 generate_data.py 里的图片换成真实照片,训练时多训几轮(比如 python train.py --epochs 40),体会一下网络在新数据上的表现。祝答辩顺利。

更多推荐