项目背景与目标
一个深度学习项目不是把网络层堆起来就结束。数据如何生成和划分、训练与验证模式是否分开、保存的是否为最佳检查点、新进程能否复现预处理,都决定最终结果是否可信。
本项目使用 Pillow 自行生成圆形、方形和三角形灰度图。每张图的大小、位置、亮度和噪声都有随机变化,避免模型只记住一个固定像素模板。输入是 32×32 PNG 图片,输出是三类概率、最终类别、训练曲线、混淆矩阵、模型权重和元数据。

三类图片使用相同的尺寸、位置、亮度和噪声范围,减少与形状无关的快捷特征。
项目用于验证 PyTorch 训练闭环,并不代表真实视觉系统。人工数据比真实拍摄图片简单,最终指标不能直接外推到其他图像分类任务。
整体架构
固定随机种子
│
▼
生成图形图片 ──► train / valid / test
│
▼
ShapeDataset + DataLoader
│
▼
TinyShapeCNN 批训练
│
┌──验证损失──保存最佳 state_dict
▼
一次测试评估
│
┌─────┼─────┐
▼ ▼ ▼
metrics.json curves.png confusion.png
│
▼
predict.py 独立加载与单图推理
项目目录:
shape-vision/
├── data/
│ ├── train/
│ ├── valid/
│ └── test/
├── model_output/
├── prepare_data.py
├── dataset.py
├── model.py
├── train.py
├── predict.py
└── requirements.txt
数据契约与划分
| 项目 | 定义 |
|---|---|
| 图片尺寸 | 32×32 单通道 PNG |
| 类别 | circle、square、triangle |
| 训练集 | 每类 120 张 |
| 验证集 | 每类 30 张 |
| 测试集 | 每类 30 张 |
| 像素输入 | 读取后转 float32,缩放到 0到1 |
| 标签 | 按固定类别列表映射为 0、1、2 |
训练、验证和测试图片独立生成,同一张图不会跨集合复用。测试集只在网络结构、训练轮数和检查点选择完成后使用。
关键实现
1. 生成自建图形数据
# prepare_data.py
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
SEED = 42
SIZE = 32
SPLITS = {"train": 120, "valid": 30, "test": 30}
CLASSES = ("circle", "square", "triangle")
rng = np.random.default_rng(SEED)
def make_image(kind: str) -> Image.Image:
background = int(rng.integers(5, 35))
image = Image.new("L", (SIZE, SIZE), color=background)
draw = ImageDraw.Draw(image)
object_size = int(rng.integers(13, 23))
left = int(rng.integers(3, SIZE - object_size - 2))
top = int(rng.integers(3, SIZE - object_size - 2))
right, bottom = left + object_size, top + object_size
fill = int(rng.integers(180, 256))
if kind == "circle":
draw.ellipse((left, top, right, bottom), fill=fill)
elif kind == "square":
draw.rectangle((left, top, right, bottom), fill=fill)
elif kind == "triangle":
draw.polygon(
[(left + object_size // 2, top), (left, bottom), (right, bottom)],
fill=fill,
)
else:
raise ValueError(f"unknown shape: {kind}")
pixels = np.asarray(image, dtype=np.float32)
pixels += rng.normal(0, 8, pixels.shape)
return Image.fromarray(np.clip(pixels, 0, 255).astype(np.uint8), mode="L")
root = Path("data")
for split, count in SPLITS.items():
for class_name in CLASSES:
folder = root / split / class_name
folder.mkdir(parents=True, exist_ok=True)
for index in range(count):
make_image(class_name).save(folder / f"{class_name}_{index:04d}.png")
print({split: count * len(CLASSES) for split, count in SPLITS.items()})
数据生成规则是项目的一部分,需要与随机种子一起保留。如果所有圆形总是更亮、所有三角形总是更暗,模型可能只用亮度分类,因此各类的随机范围保持一致。
2. 建立可追溯的 Dataset
# dataset.py
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from torch.utils.data import Dataset
CLASSES = ("circle", "square", "triangle")
class ShapeDataset(Dataset):
def __init__(self, root: str | Path) -> None:
self.root = Path(root)
self.samples: list[tuple[Path, int]] = []
for label, class_name in enumerate(CLASSES):
paths = sorted((self.root / class_name).glob("*.png"))
self.samples.extend((path, label) for path in paths)
if not self.samples:
raise ValueError(f"no PNG images found in {self.root}")
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, index: int) -> tuple[torch.Tensor, int]:
path, label = self.samples[index]
with Image.open(path) as image:
pixels = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
tensor = torch.from_numpy(pixels).unsqueeze(0)
return tensor, label
类别顺序是标签契约,训练和推理必须使用同一份 CLASSES。路径排序让样本顺序可追溯,训练时的随机顺序由 DataLoader 处理。
3. 定义小型 CNN
# model.py
import torch
from torch import nn
class TinyShapeCNN(nn.Module):
def __init__(self, classes: int = 3) -> None:
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 8, 3, padding=1),
nn.BatchNorm2d(8),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(8, 16, 3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(16, 24, 3, padding=1),
nn.ReLU(),
nn.AdaptiveAvgPool2d((1, 1)),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Dropout(0.15),
nn.Linear(24, classes),
)
def forward(self, images: torch.Tensor) -> torch.Tensor:
return self.classifier(self.features(images))
网络对图片宽高的依赖较小,但输入仍必须是单通道。分类层输出三个 logits,训练时直接交给 CrossEntropyLoss。
4. 训练、验证与最佳检查点
# train.py
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import torch
from torch import nn
from torch.utils.data import DataLoader
from dataset import CLASSES, ShapeDataset
from model import TinyShapeCNN
SEED = 42
torch.manual_seed(SEED)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
output = Path("model_output")
output.mkdir(exist_ok=True)
loaders = {
"train": DataLoader(ShapeDataset("data/train"), batch_size=32, shuffle=True),
"valid": DataLoader(ShapeDataset("data/valid"), batch_size=64, shuffle=False),
"test": DataLoader(ShapeDataset("data/test"), batch_size=64, shuffle=False),
}
model = TinyShapeCNN(len(CLASSES)).to(device)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.003, weight_decay=1e-4)
def run_epoch(loader, training: bool) -> tuple[float, float, list[int], list[int]]:
model.train(training)
total_loss, correct = 0.0, 0
predictions, targets = [], []
context = torch.enable_grad() if training else torch.inference_mode()
with context:
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
if training:
optimizer.zero_grad()
logits = model(images)
loss = loss_fn(logits, labels)
if training:
loss.backward()
optimizer.step()
batch_predictions = logits.argmax(dim=1)
total_loss += loss.item() * len(labels)
correct += (batch_predictions == labels).sum().item()
predictions.extend(batch_predictions.cpu().tolist())
targets.extend(labels.cpu().tolist())
size = len(loader.dataset)
return total_loss / size, correct / size, predictions, targets
history = {"train_loss": [], "valid_loss": [], "valid_accuracy": []}
best_valid_loss = float("inf")
for epoch in range(1, 21):
train_loss, _, _, _ = run_epoch(loaders["train"], training=True)
valid_loss, valid_accuracy, _, _ = run_epoch(loaders["valid"], training=False)
history["train_loss"].append(train_loss)
history["valid_loss"].append(valid_loss)
history["valid_accuracy"].append(valid_accuracy)
if valid_loss < best_valid_loss:
best_valid_loss = valid_loss
torch.save(model.state_dict(), output / "shape_cnn.pt")
print(epoch, round(train_loss, 4), round(valid_loss, 4), round(valid_accuracy, 3))
model.load_state_dict(torch.load(
output / "shape_cnn.pt", map_location=device, weights_only=True
))
test_loss, test_accuracy, predictions, targets = run_epoch(
loaders["test"], training=False
)
matrix = np.zeros((len(CLASSES), len(CLASSES)), dtype=int)
for actual, predicted in zip(targets, predictions):
matrix[actual, predicted] += 1
metadata = {
"classes": CLASSES,
"image_size": [32, 32],
"channels": 1,
"seed": SEED,
"torch_version": torch.__version__,
"test_loss": test_loss,
"test_accuracy": test_accuracy,
}
(output / "metadata.json").write_text(
json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
)
(output / "history.json").write_text(
json.dumps(history, indent=2), encoding="utf-8"
)
figure, axis = plt.subplots(figsize=(6, 4))
axis.plot(history["train_loss"], label="train")
axis.plot(history["valid_loss"], label="valid")
axis.set(xlabel="Epoch", ylabel="Loss", title="Shape classifier training")
axis.legend()
figure.tight_layout()
figure.savefig(output / "training_curve.png", dpi=150)
plt.close(figure)
figure, axis = plt.subplots(figsize=(5, 4))
image = axis.imshow(matrix, cmap="Blues")
for row in range(len(CLASSES)):
for column in range(len(CLASSES)):
axis.text(column, row, matrix[row, column], ha="center", va="center")
axis.set_xticks(range(len(CLASSES)), CLASSES, rotation=25)
axis.set_yticks(range(len(CLASSES)), CLASSES)
axis.set(xlabel="Predicted", ylabel="Actual", title="Confusion matrix")
figure.colorbar(image, ax=axis)
figure.tight_layout()
figure.savefig(output / "confusion_matrix.png", dpi=150)
plt.close(figure)
print("test:", round(test_loss, 4), round(test_accuracy, 3))
print(matrix)
model.train(training) 可根据布尔值切换模式。不应只依赖上下文管理器关闭梯度,Dropout 和 BatchNorm 的行为由模型模式决定。
5. 独立加载与单图推理
# predict.py
import json
import sys
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from model import TinyShapeCNN
if len(sys.argv) != 2:
raise SystemExit("usage: python predict.py IMAGE_PATH")
output = Path("model_output")
metadata = json.loads((output / "metadata.json").read_text(encoding="utf-8"))
classes = metadata["classes"]
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = TinyShapeCNN(len(classes)).to(device)
model.load_state_dict(torch.load(
output / "shape_cnn.pt", map_location=device, weights_only=True
))
model.eval()
with Image.open(sys.argv[1]) as image:
image = image.convert("L").resize(tuple(metadata["image_size"]))
pixels = np.asarray(image, dtype=np.float32) / 255.0
tensor = torch.from_numpy(pixels).unsqueeze(0).unsqueeze(0).to(device)
with torch.inference_mode():
probabilities = model(tensor).softmax(dim=1)[0].cpu()
prediction = int(probabilities.argmax())
print("class:", classes[prediction])
print("probabilities:", {
name: round(float(probability), 4)
for name, probability in zip(classes, probabilities)
})
推理脚本不依赖训练进程的内存变量,只从模型权重、元数据和输入图片重建计算。图像尺寸和类别顺序不应在多个脚本中分别手写。
运行与结果检查
requirements.txt 可记录:
torch
numpy
Pillow
matplotlib
安装 PyTorch 时应根据当前操作系统、驱动与计算设备使用官方命令生成器,不将某个 CUDA 版本的历史命令当成通用配置。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
python prepare_data.py
python train.py
python predict.py data/test/circle/circle_0000.png
find model_output -maxdepth 1 -type f -print
结果检查重点:
train360 张、valid90 张、test90 张,三类数量一致。- 训练与验证损失能正常输出,最佳权重文件存在。
- 测试集在方案选定后只评估一次。
metadata.json包含类别顺序、尺寸、通道、随机种子和 PyTorch 版本。predict.py不重新拟合模型,概率和接近 1。- CPU 和可用 GPU 环境都能按同一份代码选择设备。
错误样本与结果解读

训练曲线来自固定随机种子的实际运行。训练和验证损失一起下降,没有出现持续扩大的泛化差距。

行表示真实类别,列表示预测类别。该图只说明当前自建数据的测试结果,不能代表真实拍摄环境。
几何图形很简单,较高准确率是可预期的。比最终数字更有价值的是检查错误集中的共性:
- 过小的圆形是否容易被认成方形。
- 三角形底边接近图片边界时是否被截断。
- 高噪声图片是否集中出现错误。
- 位置偏移后准确率是否明显变化。
如果错误都集中在某一个生成参数区间,改进方向应先是补充该区间的代表性数据,而不是无条件增加网络层数。
常见问题与排查
- 目录存在旧图片:重新生成前确认数据版本,避免不同参数的样本混在一起。
- 训练准确率很高而验证低:检查划分、生成规则差异、过拟合和模式切换。
- 加载后预测类别错位:类别顺序没有随权重保存,应从元数据读取。
- GPU 显存不足:降低批次、图片尺寸或模型宽度,同时查是否保留了不必要的计算图。
- 混淆矩阵行列含义颠倒:图中明确标注行为真实类别、列为预测类别。
- 不同环境小数位不同:固定随机种子只是复现基础,设备和底层库仍可能带来差异。
- 加载来源不明的权重:拒绝未校验来源的模型文件,并保留文件哈希和生成记录。
结果、评估与阶段复盘
本项目将神经网络的主要概念放进了同一条可运行链路:图片先转成 NCHW 张量,卷积网络生成 logits,交叉熵生成损失,反向传播计算梯度,优化器更新参数,验证损失决定保留哪个检查点。
从普通程序思维进入模型训练思维的关键变化,是不再只问代码是否执行成功,而是同时追问:数据是否代表目标问题,训练信号是否正确,验证是否独立,错误集中是否存在结构性问题,产物能否在训练环境之外重建结果。
改进方向
- 在进入网络前用训练集均值和标准差归一化,并将统计量写入元数据。
- 保存错误样本路径、概率和生成参数,形成可查看的错误报告。
- 对亮度、平移、噪声和尺寸分别建立测试切片,检查鲁棒性。
- 在 CPU 环境记录单图与批量推理延迟,不只比较准确率。
- 将数据配置、模型配置和训练配置写入一份 JSON,避免多个脚本参数漂移。
- 增加图形旋转与边框遮挡,检查模型是否真正利用形状而不是固定位置。
License: CC BY-NC 4.0
Updated 2 hours ago
Was this article helpful? Give it a like.
0 comments


