news 2026/10/5 6:19:04

基于CNN的疲劳驾驶检测系统实战:SSD300与VGG16源码全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于CNN的疲劳驾驶检测系统实战:SSD300与VGG16源码全解析

简介:一套基于卷积神经网络CNN的疲劳驾驶识别检测系统源码与数据集项目,主要用于解决驾驶员疲劳状态实时识别与预警,面向正在准备毕业设计、课程设计或期末大作业的计算机相关专业学生,也适合需要项目实战练习的开发者。项目经导师指导并认可,评审分98分,整体完成度较高。压缩包共37个文件,以Python源码、编译缓存、模型权重、测试图片和说明文档为主,另含数据集与训练日志,整体大小约500MB;脚本覆盖模型构建、训练、测试、摄像头检测等核心逻辑,权重便于直接加载使用。目前已有128人学习浏览,属于较受关注的高分毕设参考。内容涵盖基于VGG16的SSD卷积神经网络目标检测模型、多个预训练权重、疲劳驾驶数据集、训练与评估脚本、摄像头及视频实时检测模块,覆盖数据准备、模型训练、效果验证到实时预警完整链路,适合对照源码快速复现并扩展改进。

1. 基于CNN的疲劳驾驶识别检测系统:这份毕设源码能直接落地吗

毕业设计里最怕的不是写代码,而是写完了模型不收敛、摄像头一开就报错、答辩现场直接翻车。这个基于卷积神经网络CNN的疲劳驾驶识别检测系统,是一套把训练、评估、实时检测串完整了的项目源码。主干用SSD300配合VGG16骨干网络做目标检测,Python环境下可以直接跑训练,也可以加载预训练权重做摄像头实时检测,压缩包里还带了训练日志、三个不同训练阶段的权重文件和FDD数据集。适合正在做计算机毕业设计、课程设计的学生,也适合想看看一个完整检测项目怎么组织文件、怎么调参的人。值不值得下,先看它能不能在你的机器上跑起来,这就是这篇文章要回答的问题。

2. 系统结构与检测原理:SSD300 + VGG16 的完整链路

2.1 先分清文件再动手:压缩包里每个文件干什么

打开压缩包后会看到二十多个 Python 文件,第一件事不是急着跑,而是先分清楚入口和依赖。我把主要文件归成几组,这样你对照着看不会乱。

分组文件作用
网络结构ssd_net_vgg.py、l2norm.pySSD300 网络定义,VGG16 做骨干特征提取
训练组件loss_function.py、augmentations.py、voc0712.py损失函数、数据增强、VOC 格式数据读取
入口脚本Train.py、Test.py、eval.py训练、单图测试、mAP 评估
检测入口detection.py、camera_detection.py、video_detection.py单张图、摄像头、视频文件三种推理模式
配置Config.py全局超参数,训练前必改
权重与数据weights、fdd-dataset.zip预训练权重、数据集压缩包

ssd_net_vgg.py 是最核心的文件,整个 SSD300 的 forward 都在这,尤其是多层 feature map 的输出。后面接的 loc 和 conf 预测层是用卷积实现的,类别数和框数量的配置都来自 Config.py,网络头部维度会跟着 num_classes 变。l2norm.py 单独拆出来,是因为 SSD 对 conv4_3 这一层要先做 L2 归一化再乘一个可学习 scale,这是个很小的模块,但在 SSD 里是必有的。voc0712.py 按 VOC 格式读图片和 xml 标注,输出的是 SSD 训练要用的目标张量,与 augmentations.py 配合完成数据进网络之前的随机裁剪、翻转和颜色变换。

还有一件事值得注意:压缩包里有一堆 cpython-37.pyc 文件,说明这个项目是基于 Python 3.7 跑通的。如果你的环境是 Python 3.10 以上,这些 pyc 文件没法直接用,需要重新解析 py 源文件,具体环境配置在第 3 章我会展开说。另外 camera_detection_1.py 看起来是调试过程留下的备份版本,运行入口以 camera_detection.py 为准,别拿错文件。

2.2 SSD300 为什么适合做疲劳驾驶检测

选 SSD 而不是 YOLO 或 Faster R-CNN,是这个项目里比较务实的选择。Faster R-CNN 两阶段结构对小目标更友好,但在单张 1080P 帧上很难做到摄像头实时;YOLO 速度快,但 SSD 在 VGG16 上做多尺度预测的调参空间更大,答辩时讲默认框设计和 NMS 也比讲黑盒更容易讲清楚。

SSD300 的核心逻辑是:骨干网络用 VGG16 的 conv4_3、fc7 以及后面几层卷积,抽出 6 个不同分辨率的 feature map,最大的是 38x38,最深层是 1x1。每个 feature map 的每个像素位置,预先铺上一组不同长宽比的默认框,框的数量和尺寸逐层递减,分辨率高的层负责小目标,分辨率低的层负责大目标。以 300x300 输入为例,6 层加起来会生成 8732 个默认框,每个框都要预测一组位置偏移和类别置信度,这也是它计算量大的主要原因,但控制在 GPU 上跑 30fps 以内没有问题。

检测流程里还有两个细节,答辩时大概率被问到。第一个是 L2Norm,conv4_3 层的特征分布跟后面层差异很大,不做归一化直接接分类层,前层特征的值会被后层压掉,所以 SSD 单独给这一层加 L2 归一化,并用可学习参数缩放。第二个是 NMS,网络输出 8732 个框后必须做非极大值抑制,把高度重叠的框合并,每类只留一个置信度最高的。这部分逻辑在 detection.py 里,你看到画面上“一堆框变成一个框”就是它在起作用。

SSD 训练阶段的损失也值得说。loss_function.py 里包含两个分支:定位损失用 Smooth L1,只对正样本框计算位置偏移;分类损失用交叉熵,对背景框和正样本框都计算。两部分相加作为总 loss,反向传播更新 VGG16 骨干和预测层的参数。训练日志里的 loc_loss 和 conf_loss 是分别打印出来的,方便定位是哪一侧出了问题。

2.3 疲劳判定不是网络直接输出的

这里要澄清一个常见误解:SSD 网络输出的是目标框和类别,它本身不告诉你“这个人困了”。这套系统的完整链路是:先由 SSD 检测出人脸或人眼区域,再在检测框的基础上做眼睛状态判定,最后按连续帧统计输出疲劳报警。

眼睛状态判定的常见做法有两种。一个是训练两个类别 open 和 closed,让 SSD 直接输出眼睛是睁开还是闭合;另一个是只检测人脸,再用 EAR(眼睛纵横比)这类几何指标闭眼程度判断。项目里有 detection.py 和 camera_detection.py 两个检测脚本,前者做单帧推理,后者是连续帧循环,实际跑摄像头时连续帧之间的统计逻辑就在 camera_detection.py 里:连续若干帧判定为闭眼,就计数,超过阈值触发报警。阈值参数在 Config.py 里可以改,第 4 章我会给出具体的调整建议,第 6 章会给一个窗口统计的参考实现。

3. 环境配置与数据集准备:跑起 Train.py 前必做的三件事

3.1 Python 3.7 + PyTorch 的环境组合

项目里的 .pyc 文件是 Python 3.7 编译出来的,我建议直接用 Python 3.7,省掉兼容性折腾。Windows 下安装时勾选 Add to PATH,装完在命令行验证版本。依赖库用 pip 安装即可:

python -m pip install --upgrade pip pip install torch==1.8.0 torchvision==0.9.0 pip install numpy opencv-python pillow tqdm

torch 和 torchvision 的版本不用太纠结,1.x 系列都能跑,关键是跟机器 CUDA 版本对应。没有 CUDA 的机器装 CPU 版也行,就是训练慢很多。装完后在 Python 里验证一遍环境:

import torch import cv2 print(torch.__version__) # 确认PyTorch可用 print(cv2.__version__) # 确认OpenCV版本 cap = cv2.VideoCapture(0) # 顺手验证摄像头能不能打开 print("camera opened:", cap.isOpened()) cap.release()

这段验证代码我每次拿到新环境都会先跑一遍。摄像头 isOpened 返回 False 的话,后面 camera_detection.py 一定报错,不如在这里提前暴露问题。

3.2 三个权重文件的用途别搞混

压缩包 weights 目录下有三个 .pth,用途完全不同,这是最容易搞混的地方。

权重文件含义什么时候用
vgg16_reducedfc.pthVGG16 预训练骨架训练时初始化骨干网络,不能直接做检测
ssd_voc_5000_plus.pth训练约 5000 步的中间模型快速验证完整流程,精度一般
ssd300_VOC_100000.pth训练 10 万步的完整模型实时检测主用权重,精度和稳定性最好

vgg16_reducedfc.pth 是训练前的初始化权重,加载它只用 vgg 部分的 state_dict,最终分类层和定位层的维度跟 SSD 不一致是正常的。后面两个是 SSD 全模型权重,加载是会要求 loc 和 conf 层维度完全匹配。训练 10 万步那个是接近完整收敛的配置,跑摄像头检测时优先加载它;5000 步那个拿来快速验证代码链路有没有问题——先跑通,再谈精度。

fdd-dataset.zip 是训练数据集,解压后按 VOC 格式组织,目录一般是 JPEGImages、Annotations、ImageSets,voc0712.py 正是按照这个约定去找图和标注,解压路径别乱改。验证权重文件是否完整可以这样做:

python -c "import torch; w = torch.load('weights/ssd300_VOC_100000.pth', map_location='cpu'); print(len(w.keys()))"

能打印出 state_dict 的键数量就说明文件完整。如果这里报错,先去检查下载是否中断,别浪费时间排查后面的代码。

3.3 VOC 格式如果不对,后面全白跑

voc0712.py 这个名字来自 VOC2007/2012,它读取 VOC 标注的核心逻辑是:根据 ImageSets/Main 里的 txt 索引,找到 JPEGImages 里对应的图和 Annotations 里对应的 xml,xml 解析成 class xmin ymin xmax ymax 格式的数组,再配合 augmentations.py 做变换后转成训练张量。如果数据集没有按这个目录结构放,或者图片和 xml 没有同名对应,训练阶段 loss 会异常,甚至每张图都读不到目标。

假设你要加入自己的数据,按下面结构放是最省事的:

data/ ├── JPEGImages/ # 所有训练图片,jpg格式 │ └── 001.jpg ├── Annotations/ # 同名xml标注 │ └── 001.xml └── ImageSets/ └── Main/ # train.txt / val.txt 索引文件

xml 里的 object 节点要严格写成这样:

<annotation> <object> <name>open_eye</name> <bndbox> <xmin>120</xmin> <ymin>80</ymin> <xmax>160</xmax> <ymax>110</ymax> </bndbox> </object> </annotation>

这个格式看起来老,但它就是 VOC 的标准,SSD 的读取逻辑只认这种结构。新增数据后,记得在 ImageSets/Main 里把新的图片名写进 train.txt,每行一个不带扩展名的文件名。我遇到过有人把图片名写成 001.jpg,结果 txt 里写的是 001,对不上还排查了很久。标注坐标越界也是一个隐蔽问题,xml 里的坐标超出图片宽高,增强时的随机裁剪会把这些框丢掉,训练数据等于白给。

注意:VOC 标注的坐标是像素值,不是归一化值。xmin、ymin、xmax、ymax 直接用整数像素坐标。

4. 训练与评估实操:Config.py 参数调优与日志解读

4.1 Config.py 里真正要改的几个参数

训练前先打开 Config.py,常见参数长这样:

# Config.py 核心参数 num_classes = 2 # 实际目标类别数 + 1(背景) batch_size = 8 # 显存不够时改4 num_workers = 4 # Windows下改成0,否则DataLoader容易卡死 max_iter = 100000 # 总训练步数 lr = 1e-3 momentum = 0.9 weight_decay = 5e-4 stepvalues = [60000, 100000] # 到第60000步lr降到1e-4,第100000步降到1e-5 vgg_weights = 'weights/vgg16_reducedfc.pth' save_folder = 'weights'

num_classes 是坑最深的参数,SSD 的分类层输出维度是 num_classes 乘以默认框数量。如果你训练目标是睁眼/闭眼两类,那 num_classes 要填 3,不是 2。类别数设错,加载 .pth 权重一定报 size mismatch。batch_size 根据显存调,6GB 以下建议 4。num_workers 在 Windows 的 spawn 模式下容易跟主进程抢资源,最稳妥设 0。stepvalues 定义了学习率分阶段下降的节点,这是让模型在训练后期稳定下来的关键,不要不设。

4.2 启动训练与 loss 观察

训练入口是 Train.py,确认 Config.py 改好后直接执行:

python Train.py

Train.py 的流程一般是:初始化 SSD300 网络,加载 vgg16_reducedfc.pth 填充骨干部分,然后进入迭代循环,每个 iter 取一个 batch,图片前向算出 loc_loss 和 conf_loss,相加后反向传播。压缩包里的 bus_dataset.log 就是一次真实训练留下的日志,可以直接拿它对比自己的训练是否健康。

看日志时重点看总 loss 和 conf_loss。刚开始训练,loss 在 5 到 7 是正常的,SSD 的定位损失用 Smooth L1,分类损失用交叉熵,8732 个默认框叠加起来初始值就是这个量级。跑两三千步后如果 loss 还在 10 以上不下来而且持续震荡,先怀疑两个原因:一是数据集标注错了,二是学习率太大。用自带 FDD 数据集跑的话,5000 步左右 loss 应该在 1 以下,10 万步那个权重最终稳定在 0.3 到 0.5 之间。训练中途想让出 GPU 给调试用,就按这个思路存检查点:

# Train.py 里常见的保存逻辑 if iter % 10000 == 0: torch.save(net.state_dict(), 'weights/ssd_voc_%d.pth' % iter)

每 1 万步存一次,这样即使中途崩溃也只丢最近 1 万步的进度,比每次都覆盖同一个文件好得多。我曾经图省事只在最后保存一次,结果训练到 9 万步崩了,直接重来,从那以后就学乖了。训练完不要只看最后一个权重,把 6 万步、8 万步、10 万步的检查点都留一份,后面评估对比会用到。

4.3 评估与测试:mAP 不是唯一指标

训练完用 eval.py 评估:

python eval.py --weights weights/ssd300_VOC_100000.pth --data data

eval.py 会遍历验证集,对每张图做推理,再跟标注计算交并比和各类别平均精度。SSD 在 VOC 上 mAP 到 70 多已经是不错的水平,但在疲劳驾驶这个场景里,mAP 只反映检测框位置准不准,不反映疲劳判定的敏感度。一个 mAP 一般的模型,只要闭眼框和睁眼框能稳定区分,实际系统照样能用。反过来,mAP 很高但闭眼漏检多,那也没意义。

Test.py 是单张图测试,输入一张测试图片输出画好框的结果图。压缩包里的 test.jpg、result.jpg、dnf_test_done.jpg 就是这一类产物。跑一下确认检测正常,再去碰摄像头,排错范围会小很多。评估时建议拿 6 万步和 10 万步两个权重各跑一遍验证集,mAP 差不到 1 个点的话,选 6 万步那个就行,推理速度快一些,答辩演示也更流畅。

5. 实时检测实战与常见问题排查:摄像头、视频、图片三类入口的坑

5.1 摄像头检测的启动与参数

实时检测入口是 camera_detection.py,命令行启动:

python camera_detection.py --weights weights/ssd300_VOC_100000.pth --threshold 0.6

代码里核心循环就是 OpenCV 的 VideoCapture 加网络前向:

import cv2 from detection import detect_ssd cap = cv2.VideoCapture(0) # 0表示第一个摄像头 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) while cap.isOpened(): ret, frame = cap.read() if not ret: continue boxes, scores = detect_ssd(frame, threshold=0.6) # boxes是NMS之后留下的目标框,scores是对应置信度 for (x1, y1, x2, y2), score in zip(boxes, scores): cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, '{:.2f}'.format(score), (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imshow('detection', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()

threshold 参数控制置信度门槛。调成 0.3,画面里会有大量误检,桌上放个瓶子都可能被框上;调成 0.9,真实的人脸又可能漏检。我一般从 0.5 到 0.6 起步,然后看实际画面里漏检多还是误检多,再往两个方向微调。注意输入帧是原始分辨率,detect_ssd 内部会先 resize 到 300x300 再送网络,检测框坐标已经映射回原图,所以可以直接在 frame 上画框。

5.2 视频文件和单张图片模式

video_detection.py 处理视频文件,本质和摄像头一样,把 VideoCapture 的入参从 0 换成本地视频路径:

python video_detection.py --video dnf_test.mp4 --weights weights/ssd300_VOC_100000.pth

detection.py 是单张图片检测,压缩包里的 test.jpg、dnf_test.jpg 就是拿来测试的样例图,跑完输出 result.jpg 或 test_done.jpg 这类结果图。三个脚本共用同一套检测逻辑,区别只在输入源。所以我的习惯是:先用 detection.py 跑一张静态图确认检测和画框正常,再调摄像头,这样摄像头一开如果还黑屏,问题范围就缩小到输入这一侧,而不是网络推理。视频检测比摄像头多一个事情:要控制跳帧,否则视频文件的解码速度跟不上推理速度,画面越来越滞后。

5.3 常见问题排查:五个高频踩坑场景

场景一:摄像头初始化成功但画面全黑

现象:cap.isOpened() 返回 True,但读出来的帧全是黑的,或者 read 一直返回 False。原因:摄像头被别的程序占用,或者笔记本的默认索引不是 0。解决:关掉占用摄像头的软件,把 VideoCapture(0) 改成 VideoCapture(1) 再试;也可以用 cap.set 降低分辨率到 640x480,缓解部分 Windows 下驱动兼容问题。

场景二:加载权重报 size mismatch

现象:加载 ssd300_VOC_100000.pth 时提示 loc_3、conf_3 层 size 对不上。原因:当前 Config.py 的 num_classes 跟权重训练时的类别数不一致。解决:先确认权重对应的类别数,VOC 官方预训练权重一般是 20 类目标加背景,也就是 num_classes 为 21;如果改成了 2 类或 3 类数据,这个权重没法直接用。这种场景只能加载 vgg16_reducedfc.pth 从头训练,或者找类别数匹配的权重文件。

场景三:训练 loss 一直不降或震荡

现象:跑了几千步 loss 还在 5 以上,上下跳。原因:大概率是 voc0712 读取标注失败,模型每 batch 拿到的都是空目标,或者增强裁剪后坐标越界把框丢没了。解决:打印一条标注检查 annotations 是否为空;再检查 xml 里坐标是否越界,越界的框在裁剪后会被丢掉,丢多了训练信号就没了。

场景四:cuda out of memory

现象:训练几步就爆显存。原因:300x300 输入加 8732 个默认框,默认 batch_size=8 对显存要求其实不低,6GB 卡很吃力。解决:batch_size 改成 4 或 2,num_workers 在 Windows 下改成 0。改完还爆就换 CPU 训练,慢但不爆。

场景五:摄像头画面卡顿,fps 太低

现象:检测框能出来,但画面像幻灯片。原因:每一帧都做一次完整前向加 NMS 后处理,连续帧循环负载太高。解决:跳帧处理,每 2 帧检测一次;或者把 threshold 调高一点减少后处理框数;还能接受的话把输入分辨率从 300x300 降到 256x256,速度和精度之间做个取舍。

6. 进阶技巧:在毕设基础上把疲劳判定做得更像产品

6.1 用自己的数据微调而不是从头训练

自带 FDD 数据集够跑通流程,但要拿高分,最好加入自己录制的闭眼/睁眼数据。采集时用手机前置摄像头拍 10 到 20 秒视频,按帧抽图,把眼睛区域抠出来标注成 open 和 closed 两类,放到第 3 章说的 VOC 目录结构里。然后不要从头训练,加载 ssd_voc_5000_plus.pth 作为初始权重继续训练,学习率调到 1e-4,迭代 5000 步左右。这样既保留原模型的检测能力,又让自有数据参与训练——答辩时这就是一个可讲的实验点。微调后的权重文件命名不要覆盖原始文件,我习惯在 save_folder 里单独建一个 finetune 目录,不然后面想对比微调前后效果时,没有后悔药可吃。

6.2 连续帧判定比单帧判定稳健得多

答辩常被问的问题是“你怎么证明系统不是误报”。单帧闭眼判定非常不稳定,镜头抖动、光线变暗、低头看手机都会触发报警。更稳的做法是统计连续帧:设定一个窗口,比如 10 帧内闭眼超过 7 帧就判定疲劳;或者持续闭眼超过 0.5 秒触发一级警告。这个逻辑在 camera_detection.py 的循环里加一个帧计数器就能实现:

frame_window = 10 # 统计窗口长度 closed_count = 0 for frames in range(frame_window): # detect_and_judge返回True表示当前帧判定为闭眼 if detect_and_judge(frame): closed_count += 1 if closed_count >= 7: # 窗口内闭眼比例超过阈值 alarm = True

闭眼时长和窗口大小的配合需要实测调整,室内光线稳定时可以放宽到 10 帧里 7 帧;室外强光下误判多,就收紧到 6 帧甚至 5 帧。窗口参数最好做成 Config.py 里的变量,训练完用摄像头录一段含真实驾驶动作的视频,离线回放调参,不要直接对着摄像头现场试,效率太低。从那以后,我每次拿到这类检测项目,第一件事都是先看 num_classes 和权重来源,第二件事是跑一张静态图确认检测链路,最后才碰摄像头。这套排查顺序帮我避开了大部分无意义的 debug 时间,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 6:18:42

快递微服务架构实战:业务域拆分与Nacos动态配置

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:18:12

步进电机开环控制系统设计:基于8086与8255A/8253的完整实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:18:11

TCP通讯录应用:协议选型与C语言实现原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:17:39

STM32上MQTT客户端选型指南:Paho、MQTT-C与coreMQTT对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:17:03

YOLOv8-OBB芯片引脚缺陷检测与TensorRT加速部署实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 6:16:57

PSMNet复现全流程踩坑记录:从环境配置到KITTI训练

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华