Python机器学习毕设实战:动漫图像分类,基于颜色直方图+HSV特征,MLP神经网络训练,混淆矩阵与训练曲线可视化,附Tkinter界面源码

“动漫图像分类”,说白了就是给程序一双“眼睛”,让它看一张动漫画面,然后告诉你看的是哪个作品:是《你的名字》还是《火影忍者》?这个题目很适合当毕业设计,因为它的套路非常经典——准备数据、提取特征、训练模型、评估效果、再做成图形界面,五步下来一个能演示的小系统就成型了。
这有一些练手的项目,完整项目:https://pan.quark.cn/s/1e54aa2ae950
下面我用大白话,把每一步代码为什么这么写、在干嘛都讲清楚,你照着做就能跑出一个带界面的项目。文章我尽量往“答辩能讲”的方向写,数据和图都给你留好。
一、先把整体思路理一理
做图像分类类题目,心里要有这张路线图:
- 准备数据:先准备一批“已经标好属于哪个作品”的图片;
- 提取特征:图片本身是一堆像素,程序不好直接算,得先变成一串有意义的数字(特征);
- 训练模型:用“特征 + 正确答案”去训练一个神经网络,让它学会分类;
- 评估:用没训练过的图去测验,看准不准,再画出曲线和混淆矩阵;
- 落地:做个带窗口的界面,让人点一下就能分类。
一句话:先有数据得特征,再用特征训出分类器,最后接上界面供人用。
二、环境与依赖
我用的是 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 样本数):
| 作品 | 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% 左右,这是非常正常的,反而更“真实”。答辩要把这个分寸讲清楚,别让人觉得你是“刷数据”。
六、第四步:命令行分类单张图(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),去感受模型(在新数据)上的真实表现。祝答辩顺利。
更多推荐

所有评论(0)