☰
道路坑洼检测用CNN二分类:大作业拿高分的关键思路与实现
2026/10/1 11:58:55 网站建设 项目流程

简介:这是一份计算机视觉课程大作业的高分参考项目,基于Python与CNN实现道路坑洼检测,适合正在完成课程设计或期末大作业的计算机相关专业学生,也适合希望借助完整项目进行CNN实战练习的学习者。资源包共14个文件,涵盖11个Python脚本、2个H5模型权重文件与1个Markdown说明文档,代码与模型均直接可用,压缩包仅10.49MB。项目包含AlexNet、LeNet-5等经典CNN网络的完整实现与训练好的权重,另有预测、测试、结果导出等模块,可快速复现道路坑洼识别流程,便于对照学习模型构建、训练与推理的核心步骤;Markdown文档则用于说明项目结构与运行方式。整套方案曾获评审98分,并已吸引905人学习下载,对于需要高质量课程设计范本或想要深入理解CNN图像分类项目的读者,具有不错的参考价值。

1. 道路坑洼检测大作业:用CNN做二分类,反而比目标检测更好拿分

每年都有不少人在“计算机视觉大作业”里选道路坑洼检测,然后一猛子扎进目标检测,在YOLO、Faster R-CNN里折腾两个月,最后交上去的代码连自己在哪都可能没调通。这里有个反直觉的经验:基于Python+CNN实现道路坑洼检测,绝大多数课程只要求“识别出画面里有坑”,并不严格区分坑的具体位置和边界。把它当成一个图像分类任务,只输出“正常/坑洼”两类,用迁移学习跑一个深度CNN,通常就已经能达到一类课程90分的水平。

这门课的核心要求往往不是模型SOTA,而是流程完整:数据加载、模型定义、训练、评估、可视化演示。下面是拆开的过程,从任务设计到数据构造,再到可运行的训练代码、答辩加分项和避坑实录,最后再教你把它做成一个能现场演示的Demo。这套方案适合三种人:正在赶大作业的学生、想快速补一个计算机视觉项目的入门者,以及需要给这题做技术预研的工程师。

2. 把“坑洼检测”翻译成CNN能学的任务:分类还是目标检测,决定了你熬夜的程度

2.1 先定任务粒度:为什么“检测”可以被降级成“分类”

“道路坑洼检测”按字面意思是目标检测(Object Detection),要在图上用框框出每个坑。但课程大作业里如果允许只给“这张图片有没有坑”的判断,那么这题的方向就完全变了:它是一个二分类问题,一条5行字的答案就省掉了几十行还要做梯度裁剪的锚框计算。

分类方案的核心价值在于:CNN的卷积特征本身就能捕捉坑的纹理特征。坑洼是个局部特征,坑洞边缘有断裂、阴影、颜色突变,这些东西在ImageNet预训练特征里已经有很多相似的纹理响应。你只需要用新的数据集对模型做微调,把最后一层换上2个神经元即可。相比YOLO的head部分要重新理解锚框和NMS,分类任务几行代码就能跑通,而且训练时间短、调参难度低。

这套思路的时间预算大概是:一个装有CPU就跑得动的笔记本电脑,MobileNetV2作为骨架,10个epoch下,分类任务大概30分钟能训完。如果用单卡训练若干G的显存,通常能到几分钟一个epoch。如果老师明确要求“给出坑的位置”,那就在这个分类模型的基础上叠加一个heatmap或者滑动窗口,但那是后话。冷知识:很多大作业评分表里写了“检测”,但答辩时看到混淆矩阵和几条PR曲线,就已经能拿一个还不错的分数了。

2.2 数据集构造:三处来源、一种目录结构、一套划分脚本

大作业第一个坎儿就是数据集。常见做法是去Kaggle或GitHub上找公共的道路坑洼图片集,搜关键词“Pothole detection dataset”“pothole image classification”能翻出不少压缩包。有的包按坑/无坑已经分好了,有的给的是原始道路图和标注json。如果你的导师发了指定数据,直接用导师的资料,但目录结构一定要统一,这一步不规整,后面训练代码会牵扯出无限的心力消耗。

我一般会把数据整理成这种结构:

pothole_data/ |-- train/ | |-- pothole/ (300张) | |-- normal/ (300张) |-- validation/ | |-- pothole/ (60张) | |-- normal/ (60张) |-- test/ |-- pothole/ (30张) |-- normal/ (30张)

去重是必做的一步。公共数据集里常有同一场景的连续帧,原封不动分进训练验证测试,会把准确率推到95%以上,但这是一种虚假的繁荣,答辩现场放几张新图就会现原形。去重可以粗放一点:先肉眼翻一遍,把相机角度几乎相同的片子删掉;要更省力的做法是计算感知哈希,把汉明距离小于阈值的留在主集里。

目录建好之后,写一个简单的划分脚本。你的源代码包里就算只放训练代码,这20行也是值得的,因为老师可能硬性要求看到“数据准备部分”。

# split_data.py # 从原始图片文件夹随机划分 train / validation / test import os import random import shutil src_root = "raw_images" # 原始图像文件夹,下面有 cracked/ 和 intact/ 两个类别子文件夹 dest_root = "pothole_data" ratio = (0.8, 0.1, 0.1) # 训练:验证:测试 常见比例,数据量大时可改为 0.9:0.05:0.05 for cls in ["pothole", "normal"]: class_src = os.path.join(src_root, cls) imgs = [f for f in os.listdir(class_src) if f.lower().endswith((".jpg", ".jpeg", ".png"))] random.shuffle(imgs) n_train = int(len(imgs) * ratio[0]) n_val = int(len(imgs) * ratio[1]) splits = {"train": imgs[:n_train], "validation": imgs[n_train:n_train+n_val], "test": imgs[n_train+n_val:]} for split_name, split_imgs in splits.items(): out_dir = os.path.join(dest_root, split_name, cls) os.makedirs(out_dir, exist_ok=True) for f in split_imgs: shutil.copy2(os.path.join(class_src, f), os.path.join(out_dir, f))

逻辑说明:上面这段脚本把raw_images下两个类别按8:1:1分成三份。选择copy而不是move,是为了保留原始数据,万一划分出了幺蛾子(比如验证集里恰好全是晴天的照片),还能重新划分,这也算是留了个后悔药。参数说明:ratio按你自己的图片量去改,如果总共只有200张图,建议改成0.7/0.15/0.15,让验证集和测试集各多几张;如果图量超过2000张,可以接受0.9/0.05/0.05,让训练集更充裕。

到这里,数据这关就过了。下一章把模型从零开始搭起来,源代码里核心的就是这几段。

3. 用Python搭一个能上皮的CNN:迁移学习MobileNetV2,以及一批关键参数

3.1 选型逻辑:为什么是好是坏都该从MobileNetV2下手

CNN骨架有很多选择:ResNet50、VGG16、EfficientNet,但大作业场景里我最常用MobileNetV2。原因不是指标最强,而是它最贴合“演示”这个核心诉求。第一,参数量少,14MB的权重,CPU机器也能跑;第二,推理速度快,单张图片在普通笔记本上只要几十毫秒,答辩时现场预测时不会一直让考官干等;第三,它对小数据集友好,迁移学习的特征提取层能直接冷启动训练。

对比起来,ResNet系列容易取得稍高一些的准确率,但收敛更慢,训练时间成本高;如果没有GPU,建议优先考虑MobileNetV2。CNN的结构解释起来也特别适合答辩:Depthwise Separable Convolution(深度可分离卷积)是MobileNet系列的标志操作,它把标准卷积拆成深度卷积和逐点卷积两步,参数数量大幅减少,这个知识点熟练背下来,基本可以应对考官对网络结构的追问。

如果老师指定要“自己设计一个CNN”,那就别用现成的预训练骨架,改写一个三层卷积:

# models/simple_cnn.py # 适合“从零搭建CNN”要求的小网络 from tensorflow.keras import layers, models def build_simple_cnn(input_shape=(224, 224, 3)): model = models.Sequential(name="simple_cnn") model.add(layers.Rescaling(1.0/255, input_shape=input_shape)) model.add(layers.Conv2D(32, (3, 3), activation="relu", padding="same")) model.add(layers.MaxPooling2D((2, 2))) model.add(layers.Conv2D(64, (3, 3), activation="relu", padding="same")) model.add(layers.MaxPooling2D((2, 2))) model.add(layers.Conv2D(128, (3, 3), activation="relu", padding="same")) model.add(layers.Flatten()) model.add(layers.Dense(64, activation="relu")) model.add(layers.Dense(2, activation="softmax")) return model

参数说明:输入尺寸固定为224x224,这和MobileNetV2一致,后面切换预训练模型时无需改图尺寸。三个卷积层的filter数量32→64→128逐步增加,符合CNN逐层抽象特征的习惯;每层都用padding="same"避免特征图快速缩边——其实用valid也行,但对于坑洼这种小尺度纹理,same保留更多边缘信息,首轮训练会更容易拟合。最后接两层全连接:一个64维的中间层做特征汇总,一个2维softmax做分类输出。

3.2 训练主文件:数据增强、冻结权重、模型存档一下到位

迁移学习是我在真实做项目时的首选,代码往下落:

# train_transfer.py # 基于MobileNetV2微调,给大作业用了“冻结骨干+只训头部”的两段式策略 import tensorflow as tf from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras import layers, models, optimizers from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping IMG_SIZE = (224, 224) BATCH_SIZE = 16 EPOCHS = 10 # 1. 数据加载:ImageDataGenerator做了在线数据增强 train_datagen = tf.keras.preprocessing.image.ImageDataGenerator( rescale=1.0/255.0, rotation_range=20, width_shift_range=0.2, height_shift_range=0.2, zoom_range=0.2, horizontal_flip=True, fill_mode="nearest" ) val_datagen = tf.keras.preprocessing.image.ImageDataGenerator(rescale=1.0/255.0) train_generator = train_datagen.flow_from_directory( "pothole_data/train", target_size=IMG_SIZE, batch_size=BATCH_SIZE, class_mode="categorical" ) val_generator = val_datagen.flow_from_directory( "pothole_data/validation", target_size=IMG_SIZE, batch_size=BATCH_SIZE, class_mode="categorical" ) # 2. 骨架:weights="imagenet",include_top=False去掉全连接层 base_model = MobileNetV2(weights="imagenet", include_top=False, input_shape=(224, 224, 3)) base_model.trainable = False # 先冻结:这一阶段只训练分类头 model = models.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.5), # 防过拟合,小数据集上尤其重要 layers.Dense(2, activation="softmax") ]) model.compile( optimizer=optimizers.Adam(learning_rate=1e-3), loss="categorical_crossentropy", metrics=["accuracy"] ) callbacks = [ ModelCheckpoint("best_model.h5", monitor="val_accuracy", save_best_only=True, verbose=1), EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True) ] # 3. 第一阶段:只训练新加的全连接层 history = model.fit( train_generator, steps_per_epoch=train_generator.samples // BATCH_SIZE, validation_data=val_generator, validation_steps=val_generator.samples // BATCH_SIZE, epochs=EPOCHS, callbacks=callbacks ) # 4. 第二阶段:解冻骨干网络的后半段,用更小学习率微调 base_model.trainable = True for layer in base_model.layers[:100]: layer.trainable = False # 前100层继续冻结 model.compile( optimizer=optimizers.Adam(learning_rate=1e-5), loss="categorical_crossentropy", metrics=["accuracy"] ) # 如果阶段一已经取得较好效果,这里Epochs可以减半 history_finetune = model.fit( train_generator, steps_per_epoch=train_generator.samples // BATCH_SIZE, validation_data=val_generator, validation_steps=val_generator.samples // BATCH_SIZE, epochs=EPOCHS // 2, callbacks=callbacks ) model.save("pothole_cnn_final.h5")

逻辑说明:这段训练脚本里最关键的是两段式训练。先用1e-3的学习率只训练新加的Dense层,理由是一个随机初始化的分类头如果直接接在预训练特征上,求出的梯度会非常大,容易把前面已经训好的特征破坏掉。把骨干冻结跑完第一轮之后,再从第101层开始解冻,用1e-5的学习率微调——这五分之一的学习率差值是经验值,目的就是让梯度更新慢一点,避免跨越原来那个优秀的损失地形。

参数说明:Batch Size在CPU机器上不要超过32,16是个稳妥值;ImageDataGenerator的旋转、缩放用了20%,如果坑洼图片里很多是远处延伸路面的视角,旋转角度太大反而会制造出反物理的样本,如果数据集偏小(不到400张),可以只保留水平翻转和缩放,把旋转调回10度;dropout 0.5是个常用中间值,调大能压过拟合,但太小又会欠拟合。ModelCheckpoint保存的是验证集准确率最高的那一版权重,不是最后一轮的,这个“后悔药”装置很值。

4. 用评估矩阵和推理脚本撑起答辩的15分钟:混淆矩阵、精确率/召回率、现场预测

4.1 让“95分”有据可依:别只报准确率,画出混淆矩阵

课程评分老师见过的报告里,超过一半只写一行“准确率95.2%”。这个数字说服力有限,因为如果正负样本比例偏斜,光说准确率根本说明不了问题。要把分拿稳,评估环节至少展示四个数字:准确率、精确率(Precision)、召回率(Recall)、F1-score,再配一张混淆矩阵。坑洼检测场景里,“把坏路判断成好路”的代价比“把好路误判成坏路”更严重,所以要把recall列出来,证明模型确实把坑捞出来了。

# evaluate.py # 对best_model.h5做评估,输出混淆矩阵和分类报告 import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing.image import ImageDataGenerator from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt model = load_model("best_model.h5") datagen = ImageDataGenerator(rescale=1.0/255.0) test_generator = datagen.flow_from_directory( "pothole_data/test", target_size=(224, 224), batch_size=16, class_mode="categorical", shuffle=False # 必须关闭,否则预测顺序和文件名对不上 ) test_gen.reset() y_pred = model.predict(test_generator, steps=np.ceil(test_generator.samples / 16)) y_pred_classes = np.argmax(y_pred, axis=1) y_true = test_generator.classes cm = confusion_matrix(y_true, y_pred_classes) sns.heatmap(cm, annot=True, fmt="d", xticklabels=test_generator.class_indices.keys(), yticklabels=test_generator.class_indices.keys()) plt.xlabel("Predicted") plt.ylabel("True") plt.savefig("confusion_matrix.png", dpi=150) plt.close() print(classification_report(y_true, y_pred_classes, target_names=test_generator.class_indices.keys()))

逻辑说明:这段代码里有三个容易踩坑的地方。第一,shuffle=False是硬性的,flow_from_directory默认会打乱数据顺序,一旦打乱,y_pred和test_generator.classes就对不上号,混淆矩阵纯属自嗨。第二,test_generator.reset()要把迭代器指针拨回开头,因为predict会消耗迭代器。第三,steps要ceil向上取整,否则最后一批不足16张的图片会被切掉,导致预测结果的行数少于图片数。

4.2 写一个“免训练”的推理脚本:单张图、文件夹、现场出结果

答辩现场最常见的尴尬是:老师随手点开一张图让你预测,你紧张地翻代码、改路径、跑训练,三分钟没动静。所以推理脚本要单独成一个文件,做成能被命令行直接调的,这就是源代码包里很耐看的一个部分。

# predict.py # 用法: python predict.py --image path/to/img.jpg import argparse import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing import image model = load_model("best_model.h5") CLASS_NAMES = ["normal", "pothole"] # 顺序必须和训练时的class_indices一致 ap = argparse.ArgumentParser() ap.add_argument("--image", required=True, help="path to input image") args = ap.parse_args() img = image.load_img(args.image, target_size=(224, 224)) x = image.img_to_array(img) x = np.expand_dims(x, axis=0) / 255.0 pred = model.predict(x)[0] label = CLASS_NAMES[np.argmax(pred)] conf = np.max(pred) print(f"预测结果: {label}, 置信度: {conf:.4f}")

注意:CLASS_NAMES的顺序不是想当然的,要确认训练时flow_from_directory里文件夹按字母序排过序。如果不放心,可以在训练完把train_generator.class_indices顺手打印出来,跟这里设的顺序比对,顺序一旦颠倒,所有结果就反了。上面的代码保存成predict.py后,答辩现场只需要一个命令行就能演示,台词大概是“老师,我加载了训练好的权重,输入一张图,模型输出类别和置信度”。

5. 道路坑洼检测的4个高频翻车现场:现象、原因、解决

5.1 训练验证准确率全上99%,新图乱猜:数据划分翻车

现象:训练集、验证集、测试集的准确率都高得离谱,甚至一堆999%的日志,但答辩时现场拍一张新图就识别错。原因:最常见的是把同一组连续帧图片在去重前就划分了,比如视频帧序列里第10帧到第20帧几乎一样,划分时随机分配到训练和验证两个集合里,模型在验证集上就等于见过原图。更隐蔽的一种是把同一条道路不同时间段的照片混在了一起,模型学的其实不是“坑”而是“沥青色泽”。解决:按“场景”而不是“单张图片”划分数据,先按文件名或拍摄位置聚类,把同一场景的照片全部分到同一个集合里。排查时看训练过程的loss曲线:如果验证loss一开始很低,后面却突然跳高,多半是划分泄露。

5.2 训练直接OOM,进程被系统卡死(“黑匣子”报错)

现象:Keras跑着跑着报一个“ResourceExhaustedError”或者直接把Jupyter内核搞死,没有任何有效traceback。原因:显存或内存被足够的图片矩阵撑爆了,尤其是224x224x3的RGB图片,一张约0.6MB,常规数据集几百张图不算大,但ImageDataGenerator的缓存和MobileNetV2的中间特征层叠加起来,16GB内存的电脑也可能卡死。解决:第一优先调小batch_size到8甚至4;如果还不行,把workers参数设为0;如果CPU机器还在卡死,那多半是内存碎片,重启内核后把flow_from_directory改为逐张读取的tf.data管道。经验值是:先把batch size调到16跑通一遍,再往上探。

5.3 loss震荡,一个epoch涨一个epoch跌,准确率一直50%上下

现象:训练loss不收敛,准确率在0.5附近震荡,像随机猜。原因:三种情况同时存在的概率很高——正负样本严重不平衡、两个类别的图片风格差异过大(一张是白天大坑。另一类是夜间远距离的模糊小坑)、以及第一段训练时学习率过大。解决:先打印各类别图片数量,如果差异小于1:2,放宽心继续训;如果差异到1:10,必须用class_weight给少的那个类别加权。Keras里传入fit的class_weight可以这样写:

# 给训练集的类别配权重,缓解不平衡 class_weights = {0: 0.5, 1: 2.0} # 假设类别1图片更少 model.fit( train_generator, class_weight=class_weights, ... )

还要确认生成器里的classes编号和你心理预期的对应。另外,学习率从1e-3起跳没问题,但也别少于1e-3了,MobileNetV2在分类头上用1e-2可能在最开始的few epochs飞速上升准确率,但后面会崩。

5.4 模型能跑,但打印出的置信度永远徘徊在0.5~0.6之间

现象:对坑洼图片预测为pothole,但置信度只有0.55;对正常图片预测为normal,置信度也只有0.6。原因是数据增强做得太狠,旋转、裁剪、缩放把坑的特征形状破坏掉了,或者训练轮数不足,分类头还停留在模糊的特征上。解决:先把数据增强停掉,只用rescale,跑一轮看验证集准确率能否上到85%以上。如果能,就说明增强策略过于激进还原;折中做法是只保留horizontal_flip和zoom_range=0.1。如果停掉增强后准确率仍然徘徊在0.6,那可能数据量太少,每个类别连100张都不足,考虑先把图片复制改成伪增强,加入亮度变换和弹性形变观察效果。

5.5 在Jupyter里跑了很久,突然发现tensorflow版本不兼容

现象:导入tensorflow.keras时各种AttributeError。原因是教学环境里的TensorFlow版本多半是2.x,但代码里混着旧版Keras的API(比如keras.layers或keras.preprocessing.image),或反过来从tf.keras里调用新函数但版本过低。解决:统一以from tensorflow.keras import ...导入,务必用pip show tensorflow检查版本,2.6以上基本稳定,2.15以上对ImageDataGenerator支持更好。我还遇到过load_model加载h5文件时缺h5py的情况,直接pip install h5py极快解决。最后一条实验室玄学:如果能从命令行换成Anaconda环境新建的Python 3.9,比在原有环境里折腾省心太多。

6. 从大作业到能写进作品集的最后一公里:把模型导出成实时Demo

大作业交完之后,源码包往往就躺在磁盘里吃灰了。如果想让它转成简历上真正硬核的“计算机视觉项目经验”,把模型往前推一步,做成一个能实时预测的界面很有必要。做法有两种:一是用TensorFlow Serving包装成HTTP接口,二是用Gradio做本地Web UI。课程答辩通常选Gradio,两行界面代码不用学前端。

# app.py # 基于Gradio的本地坑洼检测演示界面,无需前端知识 import gradio as gr import numpy as np from tensorflow.keras.models import load_model from tensorflow.keras.preprocessing import image model = load_model("pothole_cnn_final.h5") CLASS_NAMES = ["normal", "pothole"] def predict_pothole(img): x = image.img_to_array(img) x = np.expand_dims(x, axis=0) / 255.0 pred = model.predict(x)[0] label = CLASS_NAMES[np.argmax(pred)] return f"{label} (置信度: {np.max(pred):.4f})" gr.Interface( fn=predict_pothole, inputs=gr.Image(type="pil", image_mode="RGB"), outputs="text", title="道路坑洼检测 Demo", description="上传道路图片,CNN将判断是否存在坑洼" ).launch()

这段代码在答辩现场的价值非常直观:老师可以现场拍一张手机照片传上来,看到结果输出。如果还想更极限一点,可以加入视频帧的连续预测,把cv2.VideoCapture(0)的画面帧喂给模型,帧率目标10fps上下。这里有个性能经验:要把输入帧先压缩到224x224再预测,并且把预测放在一个独立线程里,避免画面卡顿。

从大作业到工程实践的边界点在于:课上跑通准确率,课下要跑通延迟。我的习惯是先确认模型推理一次的单帧耗时,再决定是否上这种实时演示——如果单帧耗时超过200毫秒,实时Demo做出来体验会很差,不如专心做静态图上传。最后再提醒一件事:交作业时把requirements.txt一并放进去,写上tensorflow、gradio、numpy、scikit-learn的版本范围,让老师能用自己的环境直接跑通,这件小事往往比模型多调两个点更能体现工程素养。希望帮到你。

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

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

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

立即咨询