news 2026/9/14 15:06:08

猪脸识别实战:细粒度图像分类与VGG/ResNet迁移学习

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
猪脸识别实战:细粒度图像分类与VGG/ResNet迁移学习

简介:这是京东JDD大赛猪脸识别项目的完整源码与说明文档,面向计算机视觉方向的学生、竞赛选手和需要课程设计或毕业设计参考的开发者,旨在帮助读者掌握基于深度学习的猪只个体识别方案。压缩包共61个文件,大小5.62MB,包含24个Python脚本、22个Markdown说明和14张示意图。Python脚本覆盖VGG16、VGG19、ResNet50等分类网络以及FCN分割网络,并配有数据增强、视频转图像、测试集生成等辅助工具;Markdown文档提供中文版说明与各模块readme;图片展示模型结构、训练曲线和分类结果,便于对照调试。目前已有233人学习浏览。整体目录按lib、classify、fcn、video_process、summary等模块划分,结构清晰,资源可直接运行,也可作为竞赛方案复盘、算法对比和论文写作的参考资料。

1. 从JDD猪脸识别看细粒度图像分类的工程化样本

猪脸识别不是猎奇,而是细粒度图像分类在养殖监控场景里少见的完整落地。京东JDD大赛这套源码把视频抽帧、数据增强、VGG系列微调、双模型融合、FCN可视化全部串在一起,拿到手不是一个只跑得通的Demo,而是一份能看清每个环节如何取舍的工程样本。压缩包里同时给了VGG16、VGG19、ResNet50和FCN四套实现,以及readme_Chinese_version.md中文说明,适合做课程设计、毕业设计素材,也适合想快速上手TensorFlow图像分类流水线的工程师。比起网上那些只有训练脚本的项目,它多出来的datatmpsummary目录反而更有信息量。

2. 源码目录拆解:数据、训练、预测三类模块怎么协同

2.1 项目根目录与核心文件映射

解压zip后,第一眼不是一堆.py堆在根目录,而是按职责拆成libclassifyfcnvideo_processdatatmp几个区域。lib里放的是基础工具,base.pyres_base.py通常负责公共预处理和模型基类;classify目录下是分类主流程,fcn目录下是语义分割和可视化;video_process负责把原始视频抽成帧;tmp则用来放中间结果和训练截图。这种组织方式本身就是一种可读性设计,工作目录、数据目录、输出目录分离,重跑时不用东翻西找。

我一般拿到这类项目,会先把根目录和每个子目录的readme.md过一遍,再把.py文件名和它负责的事情对应起来,而不是急着训练。下面这张表列出了关键文件的作用,方便对照排查问题。

文件路径作用依赖
data/Test_Adata/Test_B测试集图片,用于最终预测
data/TrainImgdata/TrainImgMore训练集图片,后者是补充样本
data/TrainVideo原始训练视频,需抽帧OpenCV
classify/vgg16_net.pyVGG16 网络定义与训练入口TensorFlow
classify/bi_vgg16_net.py双 VGG16 特征融合模型TensorFlow
classify/img_arg.py数据增强与预处理PIL / numpy
fcn/fcn.pyFCN 分割网络TensorFlow
video_process/video2image.py视频抽帧OpenCV

2.2 数据集划分:TrainImg、TrainImgMore 与 Test_A/Test_B 的用途

TrainImgTrainImgMore同时存在,是比赛数据常见的组织形式:前者是初版标注集,后者是后续补充或难度更高的图片。处理这类数据时,要特别注意文件名是否含个体ID。猪脸识别本质上是"同一个体不同图片"的分类或度量学习问题,如果文件名里带ID,就能直接做多分类;如果不带,得先做聚类或重新整理标签。从项目里get_test_csv.py这类脚本的存在可以推断,测试集是按文件名顺序预测后提交CSV的,因此训练时按ID划分验证集比全局随机划分更可靠。

实际复现时,我建议把TrainImgTrainImgMore合并到一个列表里,再按个体ID分层切分训练集和验证集,避免同一个体的图片同时出现在两边。项目没有直接给切分脚本,下面这段代码用纯Python实现按ID分层:

import os import random from collections import defaultdict img_dir = 'data/TrainImg' samples = [] for name in os.listdir(img_dir): if name.lower().endswith(('.jpg', '.jpeg', '.png')): # 假设文件名形如 pig_0123_frame_0045.jpg pid = name.split('_')[1] samples.append((pid, name)) groups = defaultdict(list) for pid, name in samples: groups[pid].append(name) train_list, val_list = [], [] for pid, names in groups.items(): random.shuffle(names) split = max(1, int(len(names) * 0.8)) train_list += [(pid, n) for n in names[:split]] val_list += [(pid, n) for n in names[split:]] print(f'train: {len(train_list)}, val: {len(val_list)}')

这段代码先按文件名第二段取个体ID,再对每个ID做80/20切分。max(1, ...)保证每个个体至少有一条进训练集,避免少数类个体在训练时完全缺失。如果文件名规则不是pig_0123这种格式,把split('_')的索引改掉即可。这里的关键点不是切分本身,而是保证验证集的个体不出现在训练集,否则验证准确率会虚高,提交到Test_B后掉点。

2.3 从视频到图片:video2image.py 的抽帧逻辑

data/TrainVideo存在说明部分训练图片来自视频。用视频抽帧有两个好处:一是能拿到连续姿态变化,二是能通过抽帧间隔控制每个个体的样本量。video2image.py的核心就是OpenCV的VideoCaptureread()循环,常见做法是按固定间隔抽帧,每5帧或10帧存一张,既能覆盖运动过程,又不会让相邻帧高度相似导致训练集冗余。

下面给出与项目用途匹配的抽帧示例:

import cv2 import os def video2image(video_path, out_dir, step=5): os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) frame_idx = 0 saved = 0 while True: ret, frame = cap.read() if not ret: break if frame_idx % step == 0: # 用视频名+帧号命名,避免后续ID解析出错 name = f'{os.path.basename(video_path)[:-4]}_{frame_idx:06d}.jpg' cv2.imwrite(os.path.join(out_dir, name), frame) saved += 1 frame_idx += 1 cap.release() print(f'{video_path}: save {saved} frames')

step是抽帧间隔,间隔越小样本越多,但相邻帧重复度越高。我一般先把step设为5,看看每个视频能出多少张图,再根据类别数量调整。如果某些个体只有一段视频,间隔可以设小一点;如果样本量已经很大,设成10反而能减少冗余。抽完帧后要用img_arg.py做清洗和增强,否则运动模糊严重的帧会直接影响训练精度。另外,cv2.imwrite对中文路径支持不好,数据放中文目录下容易静默失败,建议提前转成英文路径。

3. 图像预处理与数据增强:img_arg.py 与 get_test_csv.py 实战

3.1 尺寸归一化与标签CSV生成

深度网络输入尺寸是固定的,VGG系列一般用224x224,FCN常用512或原图尺寸。img_arg.py做的就是统一尺寸、标准化,以及后续增强;get_test_csv.py则把测试集文件名整理成CSV,方便模型批量预测后提交。对TensorFlow来说,用tf.data读CSV比逐张cv2.imread更高效,也方便同时做归一化和batch打包。

一个标准的预处理流程包含:读图、转RGB、resize到224x224、像素值归一化到[0,1]或[-1,1]。需要注意,如果加载了ImageNet预训练权重,归一化方式必须和预训练时一致。项目里没有明确写,但常见做法是用ImageNet的mean/std。下面这段兼容VGG和ResNet输入习惯:

import numpy as np from PIL import Image def preprocess_image(path, target_size=(224, 224)): img = Image.open(path).convert('RGB') img = img.resize(target_size, Image.BILINEAR) x = np.array(img, dtype=np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) x = (x - mean) / std return x

这里先除以255再减均值,是因为预训练模型大多接受0-1输入后过BatchNorm。如果训练时发现loss震荡或准确率上不去,先检查是不是预处理与预训练权重不一致。get_test_csv.py输出的格式通常是两列:文件名和预测标签,第一行是列名,后面每行一个样本。预测顺序要和CSV顺序严格一致,否则提交结果会整体错位,这也是比赛提交里最常见的翻车点。

3.2 随机裁剪、翻转与颜色抖动:img_arg.py 核心实现

数据增强的目的不是"把图片变多",而是让模型看到接近真实分布的样本。猪脸识别里,姿态、光照、遮挡变化都很大,直接训练容易过拟合。img_arg.py中典型的增强组合是:随机水平翻转、随机亮度对比度调整、随机旋转和尺度变化。水平翻转对猪脸完全适用,因为脸部左右对称;但不要做垂直翻转,那会破坏脸的几何语义。

一个可运行的增强函数如下:

import imgaug.augmenters as iaa seq = iaa.Sequential([ iaa.Fliplr(0.5), iaa.Affine(rotate=(-10, 10), scale=(0.9, 1.1)), iaa.Multiply((0.8, 1.2)), # 亮度 iaa.LinearContrast((0.9, 1.1)), # 对比度 iaa.GaussianBlur(sigma=(0.0, 0.5)) ]) def augment_image(x): return seq(image=x)

Fliplr(0.5)是50%概率水平翻转;Affine里的旋转范围控制在±10度,因为猪脸在监控画面里角度偏移有限;Multiply是亮度乘法因子,0.8到1.2对应明暗变化。增强强度不要拉满,尤其是GaussianBlur超过0.5会让纹理细节丢失,VGG的小卷积核学不到关键纹理。建议先对一个样本连续增强5次,肉眼检查有没有变形或标签语义被破坏,再放到训练管线里。

3.3 参数调整建议

数据增强不是越多越好。比赛场景里训练集固定,增强可以放大多样性,但过度增强会让模型欠拟合。以img_arg.py为例,如果每个类别只有几十张,我会把Fliplr概率提高到0.7,旋转角度扩大到±15度;如果每个类别有几百张,保持0.5和10度即可。颜色抖动要谨慎,猪栏光照变化大,但过度改变色调会让模型把颜色当成稳定特征,真实场景里换个灯光就失效。

另一个容易忽略的点是增强最好放在tf.datamap里实时做,而不是在内存里预先存好所有增强图。前者每个epoch生成不同样本,后者只是固定增量。项目里data/load.py加载图片时,可以加一个augment开关,训练时开启,验证时关闭。验证集只做resize和归一化,这样验证指标才真实反映泛化能力,而不是看过增强图后的乐观结果。

4. 主干网络选型:VGG16/VGG19/ResNet50 与 FCN 的取舍

4.1 为什么比赛场景常用VGG系列迁移学习

猪脸识别数据量有限,直接从零训练深层网络很难收敛。项目里同时放了VGG16、VGG19、ResNet50,本质上是给复现者做迁移学习对比。VGG结构简单,卷积核全是3x3,堆叠多个等价于更大感受野,且结构非常直白,加载ImageNet预训练权重后替换最后的全连接层就能适配猪脸类别数。它的缺点是参数量大,但比赛场景更看重可控性和收敛速度,VGG反而是最容易调出稳定结果的。

ResNet50引入残差结构,理论上更深也不容易梯度消失,但预训练权重默认针对ImageNet,输入尺寸和归一化要求与VGG不同。如果项目里的resnet_50.py直接用了tf.keras.applications.ResNet50,只需要设置include_top=Falsepooling='avg',再接自己的Dense层。VGG可以靠Dropout压过拟合,ResNet主要靠BatchNorm,微调策略要分开对待。

4.2 vgg16_net.py 与 vgg19_net.py 结构解析

以VGG16为例,典型配置是5段卷积,每段后接最大池化,原版最后是3个全连接层。在TensorFlow里,最常用的实现是tf.keras.applications.VGG16传入weights='imagenet',再把include_top设为False,接自定义分类头。下面是与项目命名一致的结构:

from tensorflow.keras.applications import VGG16 from tensorflow.keras.layers import Dense, Dropout, GlobalAveragePooling2D from tensorflow.keras.models import Model def vgg16_net(input_shape=(224, 224, 3), num_classes=30): base = VGG16(weights='imagenet', include_top=False, input_shape=input_shape) x = GlobalAveragePooling2D()(base.output) x = Dropout(0.5)(x) x = Dense(512, activation='relu')(x) x = Dropout(0.3)(x) out = Dense(num_classes, activation='softmax')(x) model = Model(base.input, out) return model

GlobalAveragePooling2D把最后一个卷积特征图压缩成向量,替代原版Flatten加两个大全连接层,参数量从上亿降到几千万,显存压力小很多。Dropout(0.5)放在全连接层前,是防止全连接层过拟合的常见手段。num_classes要改成数据集中猪个体数量,如果不知道具体数量,先用K-Means聚类粗算。

VGG19结构类似,只是第3、4、5段各多一个卷积层,非线性更强,但训练时间增加20%以上。项目里tmp/classify_vgg19_tensorboard.png这类日志就是用来对比两者的,最终提交很多队伍选VGG16或双模型融合,而不是单VGG19,因为精度提升有限。

4.3 resnet_50.py 加载预训练权重的注意事项

ResNet50如果直接用tf.keras.applications.ResNet50(weights='imagenet', include_top=False, input_shape=(224,224,3)),有个坑:权重里的BatchNorm层在include_top=False时依然存在,微调时不能简单冻结所有层,否则BatchNorm的均值和方差不会随输入分布更新。常见做法是冻结前100层左右,只微调后面的残差块和分类头:

import tensorflow as tf base_model = tf.keras.applications.ResNet50( weights='imagenet', include_top=False, pooling='avg') for layer in base_model.layers[:100]: layer.trainable = False x = Dropout(0.5)(base_model.output) out = Dense(num_classes, activation='softmax')(x) model = tf.keras.models.Model(base_model.input, out)

这里pooling='avg'让base_model直接输出2048维向量,省去自己写池化。冻结前100层的目的是保留ImageNet学到的底层纹理和边缘特征,后面的残差块继续适应猪脸特有的局部纹理。经验值是先固定全部骨干训练3个epoch,再解冻部分层训练10个epoch,比一开始全量微调更稳定。如果直接全量训练,BatchNorm统计量会在前几个batch剧烈波动,loss容易先飙高再慢慢降。

4.4 FCN在猪脸分割/定位中的可视化作用

fcn/fcn.py实现的是全卷积网络,没有全连接层,输出与输入同尺寸的逐像素预测图。在猪脸识别里,FCN的作用不是直接分类,而是把猪脸区域从栏舍背景中分割出来,或生成热力图帮助定位。tmp/fcn_img_*.png这些截图展示了FCN对输入图片的分割结果,可以用来检查模型到底在关注脸的哪个部位。训练FCN一般用VGG骨干的卷积层,把最后的全连接层换成1x1卷积,再上采样回原尺寸。

因为像素级标注非常贵,比赛数据通常没有现成mask,项目里的get_image.pyload_cell.py可能是配合FCN使用的,用来加载图像块并生成半自动标注。如果只是复现分类结果,FCN部分可以当辅助工具,不必追求和分类模型同等的精度,它更像是一张可视化诊断图,帮你看清楚数据里的遮挡和误检来自哪里。

5. 训练、加载与推理:load.py / bi_load.py 的完整流程

5.1 数据加载器与TensorFlow Dataset流程

classify/load.py负责把图片路径和标签组织成TensorFlow数据管道。tf.keras.utils.image_dataset_from_directory要求子目录按类别分文件夹,但比赛数据不一定这样组织,更灵活的方式是用tf.data.Dataset.from_tensor_slices传入路径和标签,再加自己的解析函数。项目里的load.py还要处理是否打乱样本、是否开启增强等开关。

下面是一个与load.py逻辑一致的加载函数:

import tensorflow as tf def load_dataset(image_paths, labels, batch_size, augment=False): def parse(path, label): img = tf.io.read_file(path) img = tf.image.decode_jpeg(img, channels=3) img = tf.image.resize(img, [224, 224]) img = tf.cast(img, tf.float32) / 255.0 return img, label ds = tf.data.Dataset.from_tensor_slices((image_paths, labels)) if augment: ds = ds.map(parse, num_parallel_calls=tf.data.AUTOTUNE) ds = ds.map(lambda x, y: (tf.image.random_flip_left_right(x), y), num_parallel_calls=tf.data.AUTOTUNE) else: ds = ds.map(parse, num_parallel_calls=tf.data.AUTOTUNE) ds = ds.batch(batch_size).prefetch(tf.data.AUTOTUNE) return ds

num_parallel_callsprefetch是关键。前者让CPU多线程解码图片,后者让GPU算当前batch时预取下一个batch。batch_size建议32或64,显存不够就降到16。验证集不要开random_flip_left_right,只做resize和归一化,否则验证指标会被增强噪声干扰。另外,decode_jpeg遇到灰度图会报错,训练图片里如果混入单通道图,要先把数据清洗一遍。

5.2 训练命令与超参数表

项目里的classify目录下有多个*_net.py,训练入口可能是直接运行对应脚本,也可能通过load.py调用。一套能稳定复现的比赛级超参数大概是:

参数推荐值说明
batch_size32根据显存调整
epochs50-100配合早停使用
optimizerSGD momentum=0.9微调时比Adam更稳
initial_lr0.001骨干冻结时
fine_tune_lr0.0001解冻后降低
dropout0.5防过拟合
label_smoothing0.1减少过度自信

训练VGG16时,我习惯先用0.001的学习率训练5个epoch,等loss平稳后,把骨干解冻,学习率降到0.0001再训练20-30个epoch。如果使用Adam,初始学习率建议0.0001,因为Adam自带自适应步长,用0.001容易在微调阶段震荡。下面是一条典型训练命令示例:

python classify/vgg16_net.py \ --train_dir data/TrainImg \ --val_dir data/TrainImgMore \ --batch_size 32 \ --epochs 50 \ --lr 0.0001 \ --num_classes 30

--num_classes要根据实际猪个体数量改,不是固定值。--train_dir--val_dir如果都指向同一份数据,程序内部要自动按ID切分,否则验证集指标虚高,提交后和本地验证对不上。

5.3 双模型融合:bi_vgg16_net.py 与结果合并策略

bi_vgg16_net.py拍的是双VGG16并联,把两个网络的特征拼接后共用一个分类头。这个做法的动机是:一个网络看到整体轮廓,另一个看到局部细节,特征层互补。比赛里常见的还有VGG16+ResNet50拼接,效果通常优于同结构双分支。项目里的classify_bi_vgg_result_*.png就是融合预测结果的可视化。

融合实现通常有两种:概率平均和投票。概率平均更适合两个模型精度有差距的情况,投票则适合精度接近的组合。bi_load.py存在的意义就是把两个单模型的输出load进来,加权平均生成最终结果。比如VGG16权重0.6,VGG19权重0.4,最后按类概率取argmax。如果bi_vgg16_net.py是真正的双网络结构,训练时两个分支共享输入,分别过不同的主干,再把特征拼接,这种结构参数量翻倍,至少需要12G显存。显存不够就老老实实单模型加TTA,也就是对同一张图做水平翻转预测后平均概率,也能涨点。

5.4 模型保存与TensorBoard可视化

项目tmp目录里有classify_vgg16_2_tensorboard.pngvgg19_graph.png,说明训练过程集成了TensorBoard。重点看训练loss、验证loss、验证准确率三条曲线。如果训练loss下降但验证loss先降后升,说明过拟合开始,应该早停或增强。fcn_cmd.pngfcn_scalar.png是FCN训练时的命令行输出和标量曲线,用来确认分割loss在收敛。

保存模型时不要只存weights,要把完整模型结构和优化器状态一起存成HDF5或SavedModel,这样恢复训练时优化器动量不丢。常见做法:

model.save('models/vgg16_pigface.h5') # 或导出供推理使用的格式 tf.saved_model.save(model, 'exported/vgg16_pigface')

加载时用tf.keras.models.load_model(path)即可。测试集预测结果先保存成npy或CSV,再统一提交,避免每次重新推理。summary/model子目录放的就是这类产物,整理后整个项目是一个可复现的闭环。

6. 复现时的三个关键技巧与验证方法

6.1 先用小数据子集跑通管线

第一次拿到源码,不要直接全量训练。从TrainImg里每个类别抽2到3张图,组成一个几十张的小数据集,跑一个epoch,确认loss在下降、TensorBoard在记录,再上全量。这样排错时间能省一大半,尤其是load.pybi_load.py里隐藏的路径拼接、类型转换问题,在小数据下会快速暴露。验证方法很简单:训练几个step后,用model.predict对同一批样本预测两次,结果应该一致,排除随机增强和不确定性问题。

6.2 识别结果的可视化验证

训练结束不要只看准确率,要看分类错误的图片长什么样。项目里的classify_bi_vgg_result_*.png就是这种用途。复现时可以用同样的方式把错误样本输出成网格图,真实标签和预测标签写在图片底部,一行排5张。如果错误集中在模糊、遮挡和极端姿态,说明数据增强还不够;如果错误集中在那几个个体之间,说明这些猪外观太相近,需要换VGG19或双分支特征融合。对每个错误样本做这个检查,比只看一条准确率曲线有用得多。

6.3 处理类别不平衡的两种实用手段

猪脸数据经常出现样本数量悬殊,有的个体几十张,有的只有几张。第一种做法是过采样,少数类图片在加载时重复送入训练集,用tf.datasample_from_datasets可以实现。第二种做法是类别加权,在损失函数里给少数类更高权重。以TensorFlow为例:

weights = compute_class_weights(labels) model.compile(optimizer='adam', loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=False), metrics=['accuracy'], weighted_metrics=['accuracy']) model.fit(train_ds, validation_data=val_ds, class_weight=weights, epochs=30)

class_weight的键是类别索引,值是权重。常见计算方式是取样本数量的倒数再归一化。加类别权重后,验证集整体准确率会偏向多数类,所以要同时看每个类别的召回率,尤其是少数类。如果少数类精度始终上不去,考虑把这些个体单独增强,而不是盲目堆训练时长。这几个技巧按顺序执行,能避开复现过程中大部分隐性坑。

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

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

用微信云开发零成本搭建个人戒烟小程序:开发全过程复盘

戒烟这事,我断断续续折腾了三年,中间复吸过两次,烟瘾上来的时候那种抓心挠肝的感觉,相信每个戒烟失败过的人都懂。后来我琢磨明白一件事:戒烟不能光靠意志力硬扛,得有个东西时刻提醒你“已经坚持了多久”“…

作者头像 李华
网站建设 2026/9/14 15:03:55

GPS信号捕获跟踪MATLAB仿真:从C/A码生成到环路实现

简介:面向GPS信号处理学习者和科研人员的完整Matlab仿真资料,聚焦信号捕获与跟踪环节,可用于验证多普勒频移估计、伪码相位搜索以及DLL/PLL跟踪环路算法,适合通信导航专业学生和算法工程师参考。压缩包内含17个文件,以…

作者头像 李华
网站建设 2026/9/14 15:02:10

基于Java的剧本杀信息管理系统开题答辩全攻略

开题答辩这件事,对于很多准备做毕设的同学来说,往往比写代码本身还让人头疼。代码不会写可以学、可以问、可以抄,可答辩PPT讲什么、老师会追问什么,这些东西一旦没准备好,真上了台就很容易卡壳。尤其是像《基于JAVA的剧…

作者头像 李华
网站建设 2026/9/14 15:02:05

MATLAB与C++混编:MEX编译配置与pui_pu53例程实战

简介:MATLAB例程结合C接口,面向车牌识别定位场景,核心是分段非线性权重改进的粒子群优化(PSO)算法。它通过优化粒子群的探索与开发平衡,提升车牌区域定位的准确性和搜索效率,适合需要研究混合编…

作者头像 李华
网站建设 2026/9/14 15:02:03

SSM框架全栈开发实战与多语言集成指南

1. 项目概述:SSM框架下的全栈开发资源整合这个标题描述的是一个以SSM(SpringSpringMVCMyBatis)框架为核心的技术资源集合项目,包含了从Java基础到企业级开发的完整技术栈资源。作为一个在JavaEE领域深耕多年的开发者,我…

作者头像 李华
网站建设 2026/9/14 15:02:01

AI语言引擎如何重构游戏出海买量与本地化协同链路

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

作者头像 李华