红绿灯识别全流程:基于CNN的数据预处理、模型训练与推理实战
2026/9/14 23:54:19 网站建设 项目流程

简介:图像分类是计算机视觉的基础任务,而卷积神经网络(CNN)是处理这类任务最成熟的深度学习模型之一。CNN通过卷积层自动提取颜色与形状特征,结合池化与全连接层完成类别预测,在交通标识识别中表现出色。现实中,红绿灯状态识别不仅依赖模型结构,更受数据质量和推理链路影响。针对32×32小尺寸输入,合理的预处理、类别映射和训练参数能显著提升模型收敛速度与准确率。本文围绕一套基于Python与Keras的红绿灯识别项目,详细解析pickle数据文件的读取与归一化、CNN网络定义、训练回调设置,以及加载model.h5/model.keras进行单张图片推理的完整过程,并总结OpenCV通道顺序、归一化缺失、类别映射错位等常见排错技巧,为开发者提供可复用的工程实践参考。

1. 从一个半夜跑崩的识别任务说起

红绿灯状态识别看着简单,真正做成一个可运行的深度学习项目时,坑往往不在模型,而在数据形态和推理链路。我拆过一份基于 Python 与卷积神经网络(CNN)的红绿灯识别源码包,里面包含main.pyred.jpgbosch_udacity_train.pbosch_udacity_test.p以及model.h5model.keras权重文件。这套资料对我来说最大的价值在于,它把常见车辆视觉任务中"图片 → 预处理 → 模型 → 状态输出"的全流程压缩到了可运行的粒度,适合刚接触深度学习的毕设学生,也适合想快速验证 CNN 训练和推理流程的一线开发者。它不追求识别精度上限,而是把数据读取、模型定义、训练保存和单张图片推理串成了一条清晰的链路,顺着源码能看懂参数,改起来也有明确边界。

2. CNN 输入侧的关键:.p 数据文件与预处理流程

2.1 先搞清bosch_udacity_train.p里存的是什么

这套源码复用的是 Bosch 与 Udacity 公开挑战赛中的红绿灯数据集,文件以 pickle 格式存放,而不是像 ImageNet 那样直接给你 JPEG 文件夹。第一次打开.p文件时,用pickle.load()后你会发现它通常是一个按键为'data''labels'的字典。data数组的 shape 一般是(样本数, 32, 32, 3),即 32×32 像素的 RGB 图像;labels是类别索引数组。为什么用 32×32?因为 CNN 对输入 size 敏感,小尺寸能大幅降低训练耗时,而红绿灯在图像中又恰好是结构简单、颜色特征显著的小物体,32×32 足够区分灯色状态。

我一般不会直接拿原始 pickle 硬灌给网络,而是先做一次形状确认和归一化。下面是一段常见的解析代码,对应源码里数据装载前的准备步骤:

import pickle import numpy as np with open('bosch_udacity_train.p', 'rb') as f: train_dict = pickle.load(f, encoding='latin1') data = train_dict['data'] # shape: (n_samples, 32, 32, 3) labels = train_dict['labels'] # shape: (n_samples,) # 归一化到 [0,1],避免原始像素值范围过大影响梯度更新 data = data.astype('float32') / 255.0 # 统计类别分布,红绿灯数据通常存在类别不平衡 unique, counts = np.unique(labels, return_counts=True) class_dist = dict(zip(unique, counts)) print(class_dist)

这段代码里有两个容易出错的地方。第一,pickle 在 Python 2 生成的.p文件可能在 Python 3 下读不出来,需要加encoding='latin1'。第二,data里某几个样本可能带 alpha 通道,如果 shape 最后一维是 4,就需要data = data[:, :, :, :3]截断。很多人在加载时报维度错误,基本都是这两个原因。

2.2 状态标签映射是分类任务的第一个决策点

红绿灯状态通常不止红、绿两类,还有黄灯和熄灭状态。源码里的labels是整型索引,所以你需要建一个映射字典来把索引转成可读的灯色名称。常见映射是{0: 'red', 1: 'green', 2: 'yellow', 3: 'off'},但不同版本的数据集映射可能不同,务必先打印np.unique(labels)确认类别数,不要想当然认为只有两类。

在训练之前,我会把数据拆成训练集和验证集。源码里只给了train.ptest.p,其中test.p是带标签的测试集,不是无标签推理集。你可以直接用train_test_split从训练集里再切出 20% 做验证,而不是拿test.p当验证集,因为那会干扰对最终泛化性能的判断。切分时要设置stratify=labels,保持各类别在训练和验证中的比例一致,否则遇到黄灯样本较少时,验证集会缺失某些类别,训练过程中准确率虚高但实际推理表现很差。

from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( data, labels, test_size=0.2, random_state=42, stratify=labels ) print(X_train.shape, X_val.shape)

这里random_state=42是为了让每次实验的可复现性保持一致。stratify参数是处理类别不平衡时最容易被忽略的一步,尤其在这种红绿灯数据里,红灯样本往往远多于黄灯,不按类别比例切分会导致验证集黄灯数量少到没法看模型真实效果。源码的README.md里如果提到数据样例数量,你也会发现同类别的数量级差异明显。这一步做扎实了,后面模型训练时看到的 loss 曲线才更有参考意义。

3. CNN 模型结构与训练参数:从model.h5model.keras的实现逻辑

3.1 用 Keras 定义适合小尺寸输入的红绿灯 CNN

这套源码里的模型保存成了model.h5model.keras两个版本,h5是旧版 Keras 格式,keras是 TensorFlow 2.x 推荐的新格式。如果你打开main.py看模型定义部分,大概率会看到一个紧凑的卷积网络。针对 32×32 的输入,常见的做法是堆 3 个卷积块,每块包含 Conv2D、BatchNormalization、MaxPooling2D 和 Dropout,最后展平接 Dense 层。这种结构参数量不大,在 CPU 上也能较快速训练,而且对灯色这种全局颜色特征和局部形状特征都足够敏感。

下面是我按这类资源最常用方案补全的模型定义,和源码的差异不会太大:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization) def build_traffic_light_cnn(input_shape=(32, 32, 3), num_classes=4): model = Sequential([ Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=input_shape), BatchNormalization(), MaxPooling2D(pool_size=(2, 2)), Dropout(0.25), Conv2D(64, (3, 3), activation='relu', padding='same'), BatchNormalization(), MaxPooling2D(pool_size=(2, 2)), Dropout(0.25), Conv2D(128, (3, 3), activation='relu', padding='same'), BatchNormalization(), MaxPooling2D(pool_size=(2, 2)), Dropout(0.4), Flatten(), Dense(128, activation='relu'), Dropout(0.5), Dense(num_classes, activation='softmax') ]) return model model = build_traffic_light_cnn() model.summary()

这个网络有几个参数值得展开说。卷积核大小(3, 3)是图像任务里最常用的局部感受野,两层 3×3 堆叠等价于一层 5×5,但参数量更少、非线性更强。padding='same'保证卷积后特征图尺寸不变,这样MaxPooling2D降采样时不会把边界信息直接丢掉。BatchNormalization放在激活函数之前,能让每一层的输入分布稳定,训练收敛明显加快,尤其在你只有几万张图、学习率又设得偏大的时候,它能防止梯度爆炸。Dropout放在池化之后而不是卷积之前,是因为卷积层本身参数量大但共享权重多,池化后的特征更紧凑,dropout 效果更明显。

3.2 编译参数和学习率策略

模型编译是训练前最后一道关键决策。红绿灯分类是多分类问题,损失函数用categorical_crossentropysparse_categorical_crossentropy。如果你的标签是整数索引,用后者可以省去 one-hot 转换的预处理步骤;如果用前者,则需要to_categorical(y_train, num_classes=4)。优化器我一般先用Adam,初始学习率1e-3。Adam 适合这类中等规模数据,因为它对学习率不那么敏感,而且能自适应调整每个参数的学习步长。

from tensorflow.keras.optimizers import Adam from tensorflow.keras.losses import SparseCategoricalCrossentropy model.compile( optimizer=Adam(learning_rate=1e-3), loss=SparseCategoricalCrossentropy(from_logits=False), metrics=['accuracy'] )

from_logits=False意味着模型输出已经经过 softmax,损失函数内部会直接拿概率分布和标签计算交叉熵。如果模型最后一层没有softmax,这里就要改成True。训练时设置batch_size=64epochs=30,同时加入ModelCheckpoint回调保存最佳权重,这样即使训练中途断掉,你手里仍有model.h5model.keras可恢复。源码里不带训练脚本也正常,因为权重文件已经摆在这里,重点是推理部分,但如果你从零训练,下面这段回调配置就能直接抄:

from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping checkpoint = ModelCheckpoint( 'best_model.h5', monitor='val_accuracy', save_best_only=True, mode='max' ) early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), batch_size=64, epochs=30, callbacks=[checkpoint, early_stop] )

monitormode要配套,比如监控val_accuracymode='max',监控val_lossmode='min'patience=5表示连续 5 个 epoch 验证 loss 不下降就提前停止,防止过拟合。这个项目里,因为数据集相对固定,直接训练 30 个 epoch 通常就能看到验证准确率稳定在 96% 以上,所以我一般不开EarlyStopping,只开ModelCheckpoint,让训练跑满并保留最好的一版。

3.3 训练过程中需要盯住的三个指标

只看训练准确率很容易被误导。红绿灯数据的显著特点是类别不平衡:红灯样本多、黄灯样本少、熄灭状态更少。训练时你要同时盯住训练/验证 loss 曲线、验证准确率、以及每个类别的召回率。验证 loss 先降后升是过拟合信号,这时应该减小网络容量或增大 Dropout。验证准确率高但黄灯类别召回率特别低,说明模型把黄灯都预测成红灯了,这时需要给黄灯类别更高的权重,或者在 Loss 里加上类别权重。

我通常会在训练结束后跑一次分类报告,而不是只打印model.evaluate的平均准确率:

from sklearn.metrics import classification_report y_pred = model.predict(X_val) y_pred_classes = np.argmax(y_pred, axis=1) print(classification_report(y_val, y_pred_classes, target_names=['red', 'green', 'yellow', 'off']))

这份报告会输出每个类别的 precision、recall、f1-score。如果yellow的 recall 低于 0.85,说明模型在这个类别上学习不充分。解决办法有两个方向:一是数据增强中对黄灯图像做水平翻转和亮度扰动,让模型看到更多黄灯变体;二是在model.fit中传入class_weight,比如{0: 1.0, 1: 1.0, 2: 2.0, 3: 1.5},把数量少的类别惩罚放大。源码里没有直接给增强代码,但这是处理该数据集的通用手段,加在ImageDataGenerator里即可。

4.main.py推理链路与模型加载排错实战

4.1 从red.jpg到预测结果:推理代码逐行拆解

项目里的main.py是推理入口,它加载model.h5model.keras,读入red.jpg,输出红绿灯状态。这段代码我会重点看三部分:模型加载方式、图像预处理尺寸、后处理逻辑。下面是一段符合该资源结构的推理核心代码:

import sys import numpy as np from PIL import Image from tensorflow.keras.models import load_model def preprocess_image(image_path, target_size=(32, 32)): img = Image.open(image_path).convert('RGB') img_resized = img.resize(target_size) arr = np.array(img_resized, dtype='float32') / 255.0 # 转换维度:从 (32,32,3) 变为 (1,32,32,3),匹配 batch 输入 input_batch = np.expand_dims(arr, axis=0) return input_batch def predict_traffic_light(model_path, image_path): model = load_model(model_path) input_batch = preprocess_image(image_path) predictions = model.predict(input_batch, verbose=0) class_index = np.argmax(predictions[0]) confidence = float(predictions[0][class_index]) labels = ['red', 'green', 'yellow', 'off'] return labels[class_index], confidence if __name__ == '__main__': state, conf = predict_traffic_light('model.h5', 'red.jpg') print(f"State: {state}, Confidence: {conf:.2f}")

这里最关键的是preprocess_image。模型训练时输入是(32, 32, 3),推理时图片可能是任意分辨率,必须用resize到 32×32,并且要使用与训练时一致的插值算法。PIL 默认的Image.resize用双线性插值,而 Keras 内部处理可能用到其他方式,所以如果你发现结果图像颜色偏移,可以尝试Image.LANCZOS作为resample参数,但对灯色识别影响通常不大。expand_dims是必须的,因为模型预测需要一个 batch 维度,很多新手直接丢掉二维数组进去会报ValueError: Input 0 of layer "sequential" is incompatible with the layer

4.2 加载model.h5model.keras的兼容性问题

项目中同时存在model.h5model.keras是有原因的。model.keras是 TensorFlow 2.6 之后推荐的格式,它保存了完整的模型架构、优化器状态和编译信息,用load_model可以直接恢复。而model.h5是旧版 HDF5 格式,可能在加载时报ValueError: Unknown layer: BatchNormalization或者NameError: name 'Adam' is not defined,尤其当你的 TensorFlow 版本和保存时不一致时。

如果碰到这类问题,建议按优先级尝试:先升级tensorflow到 2.15 以上,或者把自定义层注册到custom_objects。但源码里的模型完全由标准 Keras 层构成,一般不需要自定义层。一个更稳妥的办法是用model.keras重新保存一次 h5 格式:

python -c "from tensorflow.keras.models import load_model; m=load_model('model.keras'); m.save('model_from_keras.h5')"

这样得到的 h5 文件在当前环境下就是可加载的。注意,如果你的环境里只有model.h5没有model.keras,可以尝试在load_model时传入compile=False,先加载权重和架构,再手动编译,这样能绕开优化器状态不兼容的问题。

4.3 推理结果不正确的排错思路

当你把一张红灯图片喂进去,模型返回green,先别急着骂模型。按顺序检查下面几项。

第一,检查输入图像通道顺序。训练数据是 RGB,但 OpenCV 的cv2.imread读出来是 BGR,如果你在main.py里用了 OpenCV 但没有转换通道,模型看到的颜色通道就已经交换了,红灯和绿灯可能直接互换。解决办法是cv2.cvtColor(img, cv2.COLOR_BGR2RGB)或者干脆统一用 PIL。

第二,检查归一化范围。训练时用data / 255.0,推理时如果忘了除以 255,模型输入分布完全不同,输出概率几乎是随机猜测。上面代码里我已经把归一化写进preprocess_image

第三,检查类别映射顺序。发布源码的人可能用{'red':0, 'green':1, 'yellow':2, 'off':3},但另外一些版本可能是0=green, 1=red, 2=yellow, 3=off。如果映射错位,一个红灯图片预测出green的概率很高,但模型本身其实是对的。最可靠的验证方式是找一张测试集里已知标签的图片,打印np.argmax(model.predict(...)),然后人工比对labels数组里的顺序是否与数据集生成脚本一致。源码的README.md一般会对类别顺序有描述,如果没有,就用这种人工比对法去反推。

下表总结了我在排查时最常遇到的三个坑和对应处理方式:

症状可能原因处理方式
红灯预测成绿灯且置信度极高OpenCV 的 BGR 通道未转 RGB改为 PIL 读取或用cv2.COLOR_BGR2RGB
所有图片都预测为off或固定类别输入未归一化,或模型加载失败退化到默认权重检查x/255.0,重新加载模型
训练时准确率高但推理差预处理尺寸、插值方式、通道顺序不一致统一用preprocess_image里的逻辑,并且加测试集验证

5. 进阶技巧:把固定模型变成能应付复杂场景的状态识别器

这个层面的改进不是简单调参,而是从数据与推理策略上提升泛化能力。源码本身只对单张 32×32 图片分类,但实际车载场景里,红绿灯在画面中占比小,且有多灯同时亮、逆光、模糊等情况。我一般会在现有模型基础上做三件性价比最高的事。

第一,多尺寸投票推理。把输入图片缩放到 24、32、40 三种尺寸,分别送入模型,得到的预测类别取投票,置信度取平均值。这样做能缓解训练尺寸和实际目标尺寸不匹配的问题。代码上只需要循环调用preprocess_image并传递不同的target_size,最终组合结果。对于视频流场景,这种多帧投票也能减少闪烁,但源码只针对单张图片,所以我会额外写一个滑动窗口对连续帧的类别序列做平滑。

第二,关注分类置信度而不是只取最高分。红绿灯状态识别里,off状态和yellow状态在阴影下容易混淆,模型可能会给两个类别的概率都很接近,比如 red=0.48, off=0.45。这时候如果直接取argmax,输出会不稳定。更稳的处理是设定置信度阈值,比如当最高概率低于 0.6 时,输出unknown并根据前一帧的预测结果做状态保持。在工程实现上,这就是把main.py里的predict_traffic_light扩展成带状态缓存的类。

class TrafficLightPredictor: def __init__(self, model_path, threshold=0.6, state_keep=3): self.model = load_model(model_path) self.threshold = threshold self.labels = ['red', 'green', 'yellow', 'off'] self.prev_states = [] def predict_frame(self, image_array): proba = self.model.predict(image_array, verbose=0)[0] idx = np.argmax(proba) confidence = proba[idx] if confidence < self.threshold: # 低置信度时保留最近出现过的状态 if self.prev_states: return self.prev_states[-1], confidence return 'unknown', confidence state = self.labels[idx] self.prev_states.append(state) self.prev_states = self.prev_states[-self.state_keep:] return state, confidence

state_keep参数控制保留最近几帧的状态,实际效果是让识别结果具有时序稳定性。这个做法不会改变模型本身,但对最终输出的鲁棒性提升非常明显。阈值threshold的选择可以通过验证集上的置信度分布来确定:统计所有错误预测样本的置信度,通常错误样本的置信度偏低,取一个能让错误样本大量落入阈值以下的数值即可。

第三,模型部署时转换为 TensorFlow Lite 格式。如果项目要放进移动端或嵌入式设备,model.keras原生格式往往太庞大。用下面的命令转换成.tflite,比h5小约 75%,推理速度在 CPU 上能提升 2 到 3 倍:

python -c " import tensorflow as tf converter = tf.lite.TFLiteConverter.from_keras_model(tf.keras.models.load_model('model.keras')) converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() open('traffic_light.tflite', 'wb').write(tflite_model) "

转换后我一般会立刻用tf.lite.Interpreter加载并跑一遍red.jpg,确认输出类别与model.h5一致。Optimize.DEFAULT会把权重从 float32 量化到 float16,精度损失在灯色识别这类任务上几乎不可见。量化过的模型还能进一步转成 int8,精度可能会下降 1% 左右,但对特征明显的红色和绿色仍然有效。

这套源码真正适合的用法是作为基线,后续所有针对真实场景的优化都落在数据和推理策略上,而不是盲目加深网络。模型结构已经足够提取灯色特征,剩下的问题是让它在光照变化和时序抖动中保持稳定输出。你能从这个 zip 里带走的最有用的资产,其实是那套从.p数据到推理结果的代码骨架,它让你在替换成自己的数据集时,只需改类别的映射和输入尺寸,就能快速复用到其他小的视觉分类任务。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询