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

这有一些练手的项目,完整项目: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 该类别样本数):

角色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%),那是正常的,反而更显得“真实”。答辩的时候这种分寸要讲清楚: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),体会一下网络在新数据上的表现。祝答辩顺利。

更多推荐