news 2026/9/28 1:07:20

深度学习图像分割实战指南:从选型到推理避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度学习图像分割实战指南:从选型到推理避坑

简介:面向计算机视觉与人工智能方向的开发者,这份Python实战资源完整覆盖了图像分割领域的三大核心任务——语义分割、实例分割与全景分割。项目源自毕业设计,代码经过严格测试并全部运行成功,答辩均分达96分,适合作为课程作业、毕业设计或竞赛项目的起点。压缩包内共5个文件,包含3个Markdown设计说明、1个Python数据预处理脚本以及1个Qt工程文件,整体仅11KB,结构精简。其中Markdown文档提供项目说明与使用指引,Python脚本可实现标签图到单通道等预处理操作,工程文件便于直接打开调试。资源已获得206人学习浏览,内容涉及CCF遥感图像分割、RSSRAI2019语义分割等具体赛题场景,读者可以据此快速理解深度学习图像分割的完整流程,并借助带评分的成熟项目进行二次扩展。

1. 拿到的这套图像分割项目,先想清楚它是"方法课"不是"开箱软件"

"基于深度学习的图像分割"这个标题里藏着一个容易被忽略的分岔:它一次讲了三种任务——语义分割、实例分割、全景分割。很多人下载完源码包就急着找现成权重,结果发现训练脚本、数据转换、可视化工具占了大头,模型定义只是几个文件。这正是这类项目该有的形态:它是方法论,不是点开即用的软件。它能解决的问题是从零搭一套分割模型的完整链路——数据怎么标注、模型怎么选、训练怎么收敛、输出怎么还原成图;适合要交毕设课设代码和文档的学生,也适合工作中想快速跑通基线再优化的工程师。后面的落地路径按选型、数据、训练、推理、避坑五段推进,源码结构会穿插在对应的环节里讲,读代码时心里才有底。

2. 三类分割的模型选型:任务定义不同,网络头就长不一样

分割模型的选型不是按"哪个准"凭空选的,而是按"输出需要什么结构"选的。语义分割输出一张逐像素标签图,实例分割输出一组"框 + 掩码 + 类别"的列表,全景分割要给出全图唯一标签。任务定义不同,网络头结构就完全不同。这一章先把每个任务的原理讲透,再给一张实战选型表。

2.1 语义分割:全卷积结构与 DeepLab 系列为什么稳

传统 BP 网络做图像分割的时代已经过去了,原因是它把像素当独立样本处理,完全丢掉了空间上下文。现在语义分割清一色是 encoder-decoder 全卷积结构:编码器下采样提语义,解码器还原分辨率,最后接 1x1 卷积把通道数压成类别数。FCN 是最早证明"全卷积可行"的模型,今天没人再用裸 FCN,但它留下的底座思想所有语义分割模型都在用。

工程上验证最多的选择是 DeepLabv3+ 和 PSPNet 两条线。DeepLabv3+ 用空洞卷积在不下采样时扩大感受野,再用 ASPP 模块从多个膨胀率并行提特征,对广告牌、路面这种大尺度目标效果稳。实际操作里我优先选 DeepLabv3+ 系,理由很朴素:它对输入分辨率变化不敏感,换数据集、换相机内参不容易出现玄学掉点。如果任务换成医学图像分割这类样本少、目标小的场景,U-Net 的跳连接更划算,浅层细节直接被拼到解码器,小病灶边缘不会在下采样里被洗掉。

模型输出形态很好认:一张和输入同尺寸、通道数等于类别数的 logits 图。下面是简化版分割头,骨干输出的 stride 一般是 4 的倍数:

class SegHead(nn.Module): def __init__(self, in_channels, num_classes): super().__init__() # 1x1 卷积把骨干特征压到类别数,保持空间分辨率 self.classifier = nn.Conv2d(in_channels, num_classes, kernel_size=1) # 把低分辨率 logits 还原回原尺寸 self.upsample = nn.Upsample(scale_factor=4, mode='bilinear', align_corners=False) def forward(self, x): logits = self.classifier(x) # (B, num_classes, H/4, W/4) return self.upsample(logits) # (B, num_classes, H, W)

align_corners=False是 PyTorch 上采样的推荐设置,把像素当单位而不是格子中心,边界不会出现半像素偏移。scale_factor必须和骨干实际 stride 对应,否则训练时 logits 和标注 mask 尺寸对不上,报错还算好查,推理时叠加原图错位才难查。

提示:PyTorch 的 upsample 和骨干配置里的 output_stride 经常是两个口径,出问题先核对这两个值是否一致。

2.2 实例分割:Mask R-CNN 的两阶段设计与 RoIAlign 的关键作用

实例分割比语义分割多一层不确定性:目标数量不定,网络不能只输出一张固定尺寸的图,必须先把"有几个目标、每个在哪"解掉。最成熟的路线是两阶段:RPN 先扫特征图提出候选框,RoIAlign 把每个框的特征裁出来并归一化到固定尺寸,后端再接三个分支——类别、框回归、mask。

这段方案里最值得细看的是 RoIAlign。它用双线性采样替代了 RoIPool 的整数取整,消除特征与原图之间几像素的错位。几像素对分类无所谓,但对 mask 是致命的,边界差 2 个像素,可视化时轮廓就像浮在背景上。现在很多工程还在用 Mask R-CNN 不是因为它新,而是因为它 mask 质量和训练稳定性在通用场景下最不容易翻车。

经常被拿来对比的是 YOLOv8-seg 这类单阶段方案。它把检测和分割揉在一个头里,速度快,适合实时交互;但 mask 分支直接在框内回归固定尺寸掩码,精细边缘弱一些。如果你想分清楚 YOLO 实例分割和语义分割的区别,记住一句话:语义分割输出的是一张整图标签,实例分割输出的是一组"掩码 + 框 + 类别"的列表。

class MaskHead(nn.Module): def __init__(self, in_channels, num_classes, mask_size=28): super().__init__() self.conv = nn.Conv2d(in_channels, 256, 3, padding=1) # 每个类别预测一张小掩码 self.mask = nn.Conv2d(256, num_classes, 1) self.mask_size = mask_size def forward(self, roi_feat): x = F.relu(self.conv(roi_feat)) # 输出 (B, num_classes, 28, 28),推理时放大到 bbox 尺寸 return self.mask(x)

mask_size=28是论文延续下来的惯例,训练时所有框内特征统一压到 28x28 算损失,推理时再双线性放大到原框大小。这个设计让 mask 分支计算量不随目标大小变化,代价是丢失高频细节。不要指望 28x28 能还原毛玻璃边缘,它给的是语义上完整的轮廓。

2.3 全景分割:UperNet 的合并思路与 stuff/thing 的先后次序

全景分割是三类里最"强制统一"的任务:它要求全图每个像素只有一个唯一标签。它把类别拆成两类——stuff 是天空、地面这类没有固定数量与形状的背景,thing 是人、车这类可数目标。网络通常是一个共享骨干挂两个头:语义头做稠密分类,实例头做检测加掩码。

UperNet 和 Panoptic FPN 是两种常见实现,主轴都是 FPN 特征金字塔。全景分割的代码量大部分不在网络,而在最后一步合并。合并顺序有讲究:常见做法是先把 stuff 铺底,再把 thing 按置信度从高到低往上盖,thing 永远覆盖 stuff。反过来做的话,车停在路边,语义分支把路面标签盖到车的 mask 上,全景图里就出现"车身内部是路面"的像素级矛盾。

def merge_panoptic(stuff_mask, instances, inst_offset): panoptic = stuff_mask.copy() # (H, W),stuff 类别 id for inst_id, inst in sorted(enumerate(instances), key=lambda item: item[1].score, reverse=True): m = inst.mask & (panoptic == 0) # 只覆盖尚未被占用的像素 panoptic[m] = inst_offset + inst_id return panoptic

inst_offset通常取"stuff 类别总数 + 1",让最终像素值可以唯一反查出"第几个实例、什么类别"。合并逻辑本身代码量不大,但它是整条管线里最容易做错的一环,细节放在避坑章。

2.4 模型选型对照表和源码包优先看哪几个文件

选型我习惯按"mask 精细度"和"推理速度"两个坐标来挑。下面按 512x512 输入、batch size 4 粗估,绝对值随 backbone 变,相对关系基本稳定:

任务常用模型显存占用推理速度mask 精细度典型场景
语义DeepLabv3+中快逐像素遥感、路面分割、广告牌分割
语义U-Net低中逐像素医学图像分割、小样本
实例Mask R-CNN高慢精细通用目标 + 精确轮廓
实例YOLOv8-seg中快较粗实时交互
全景UperNet / Panoptic FPN高慢兼顾自动驾驶、城市理解

拿到源码包以后不要急着读模型定义,按三个文件顺序看。先看 data 目录下的数据集类和转换脚本,这决定换自己数据时的改造成本;再看模型文件里每个输出头的输出通道数,通道数是"类别数"还是别的设计,一眼能看出作者怎么定义任务;最后看 train 脚本里的 loss 组合,交叉熵、dice、focal 的配比直接反映调参思路。设计资料文档一般讲原理和结构图,代码目录是真正要动刀的部分。

3. 数据与训练管线:从标注工具到损失函数的完整链路

数据准备是分割项目里最花时间也最不受重视的一段。别急着跑训练,先把标注转格式、边界情况跑通,不然后面每个环节都会反馈出一个数据问题。开始训练前也先确认 python 版本和 CUDA 的对应关系,很多环境问题最后都是 torch 与显卡驱动不匹配引起的,这比调参更影响进度。

3.1 语义分割数据集制作:LabelMe 标注转 VOC 格式的三步

标注工具用 LabelMe,因为它存的是多边形,比像素级涂抹快一个量级。前置动作是准备类别映射表:

class_map = {"void": 255, "unlabeled": 0, "sky": 1, "road": 2, "car": 3, "person": 4}

这个表要固定下来,后面转换、训练、评估都用它。0 留给 unlabeled,255 留给 ignore,真实类别从 1 开始,这是避免背景类别和 void 混淆的关键约定。标注完成后每个图片对应一个同名 json,转换脚本如下:

def json_to_mask(json_path, class_map, img_size): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) mask = np.zeros((img_size[1], img_size[0]), dtype=np.uint8) for shape in data['shapes']: pts = np.array(shape['points'], dtype=np.int32) # fillPoly 按多边形填充,凹多边形也正确 cv2.fillPoly(mask, [pts], class_map[shape['label']]) return mask

dtype用 uint8,类别超过 254 再升 int16。fillPoly对顶点顺序不敏感,凹多边形没压力,多边形没闭合也会帮你闭合。转换完以后务必抽样检查一张 mask,和原图叠加看类别 id 是否正确,这个动作能省下后面三天排错时间。

3.2 把 VOC 转 COCO:转换脚本与四个边界坑

实例分割和全景分割训练基本都要 COCO 格式,因为它要segmentation、bbox、area这类字段。常见做法是直接从 LabelMe json 转 COCO,不要从 PNG mask 反推 polygon,反推会丢原标注平滑度:

def voc_to_coco(images, annotations, categories): coco = {"images": [], "annotations": [], "categories": []} for cat_id, name in enumerate(categories, start=1): coco["categories"].append({"id": cat_id, "name": name}) for ann_id, (img_id, bbox, polygon, cat_id) in enumerate(annotations): xmin, ymin, xmax, ymax = [float(v) for v in bbox] coco["annotations"].append({ "id": ann_id, "image_id": img_id, "category_id": cat_id, "bbox": [xmin, ymin, xmax - xmin, ymax - ymin], "area": cv2.contourArea(np.array(polygon, dtype=np.int32)), "segmentation": [np.array(polygon).flatten().tolist()], "iscrowd": 0 }) return coco

四个边界坑逐个说。第一,category_id在 COCO 里从 1 开始,0 是背景,别把 VOC 的背景当类别带进来。第二,bbox格式是[x, y, w, h],VOC 给的是[xmin, ymin, xmax, ymax],转换时方向别搞反,而且要做边界裁剪w = max(0, min(w, img_w - xmin)),防越界。第三,polygon 要首尾闭合,COCO 校验会直接报 segmentation 格式错。第四,area要填真实多边形面积而不是w * h,否则评估时 mAP 按面积分档会错位。

3.3 训练循环与损失函数:CrossEntropy 加 Dice 的组合

训练循环本身不复杂,难在损失函数。纯交叉熵的问题大家都熟:背景像素占大多数,小物体梯度被稀释,训练几个 epoch 后 mIoU 还在涨,可视化却发现小目标全灭。工程上最省事的解法是 CE 加 Dice:

for images, masks in train_loader: images = images.cuda() masks = masks.cuda() # (B, H, W),值是类别 id,ignore 为 255 logits = model(images) # (B, C, H, W) ce = F.cross_entropy(logits, masks, ignore_index=255) dice = dice_loss(logits, masks) loss = ce + 0.5 * dice # dice 权重 0.3~0.5 足够

ignore_index=255让标注里涂成 255 的区域不参与反传,是通行约定。dice 权重我一般给 0.3 到 0.5,超过 1 训练容易震荡,因为 dice 在小目标上的梯度本身不稳。学习率从 1e-4 起步,用 poly 策略衰减;batch size 在显存允许下尽量不小于 4,BatchNorm 在小 batch 下统计量抖得厉害,分割对这点比分类敏感得多。

def dice_loss(logits, target, eps=1e-6): probs = F.softmax(logits, dim=1) onehot = F.one_hot(target, num_classes=probs.shape[1]) onehot = onehot.permute(0, 3, 1, 2).float() inter = (probs * onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + onehot.sum(dim=(2, 3)) dice = (2 * inter + eps) / (union + eps) return 1 - dice.mean()

permute(0, 3, 1, 2)因为F.one_hot把类别维放在最后,要挪到第二维与 logits 对齐。eps防某个类别在 batch 里完全缺席时除零。这个版本对每个类别独立算 dice 再取平均,和"整体前景算一个 dice"的版本数值差异明显,写文档时务必说明用的是哪一种,避免复现对不上。

4. 推理与可视化:把模型输出变回人能看的图

训练收敛后,真正的工程问题才开始:模型输出的是高维张量,怎么变成能存、能叠、能汇报的图。这一章给三类分割各自的推理后处理,每个步骤的参数都直接影响可视化质量。

4.1 语义分割推理:argmax、类别映射与 color map

推理时模型输出 (C, H, W) 的 logits,第一步argmax(dim=0)得到类别 id 图,第二步映射成可视化颜色。翻车点在于把类别 id 直接当灰度值存 PNG,类别 id 只有 0~20,存出来一片黑。

def visualize_semantic(pred_ids, color_map): H, W = pred_ids.shape rgb = np.zeros((H, W, 3), dtype=np.uint8) for cls_id, color in color_map.items(): rgb[pred_ids == cls_id] = color # 每个类别一种颜色 return rgb

color map 建议用 Cityscapes 风格的固定映射,类别间色差拉开,相邻色容易让背景和前景混在一起。调参技巧:把pred_ids == cls_id的 mask 单独存成二值图看,比看叠加图更容易定位漏检区域。

4.2 实例分割推理:score 阈值、NMS 与 mask 还原到原图

实例分割推理有三个参数最常调,逐个说。score_threshold决定保留哪些检测,一般 0.5,漏检多就降到 0.3,但降到 0.3 后误检也会变多,需要看 PR 曲线再定。NMS 的 IoU 阈值 0.5 是均衡值,目标重叠多的场景可以调到 0.3。mask 二值化阈值 0.5 是把 mask logits 变成 0/1 掩码的最后一步,边缘毛刺严重时降到 0.3 试。

detections = model(images) # boxes, masks, scores for det in detections: keep = det.scores > 0.5 boxes = det.boxes[keep] masks = det.masks[keep] # (N, 28, 28) # 正确顺序:先放大到 bbox 宽高,再按左上角贴回原图 resized = F.interpolate(masks.unsqueeze(0), size=(box_h, box_w), mode='bilinear') full_mask = np.zeros((img_h, img_w), dtype=np.uint8) full_mask[ymin:ymax, xmin:xmax] = resized.squeeze(0).sigmoid() > 0.5

这里是最常踩的坑:mask 是相对 bbox 的,不是相对原图的。正确流程是先把小掩码放大到 bbox 的宽高,再按 bbox 左上角坐标放回原图。放大用 bilinear,不要用 nearest,否则二值化边缘呈锯齿。上一段代码里full_mask[ymin:ymax, xmin:xmax]这一步就是相对坐标转绝对坐标,漏掉它 mask 就会跑到框外面。

4.3 全景分割后处理:stuff 铺底、thing 覆盖的完整步骤

全景分割推理就是把 4.1 的语义图和 4.2 的实例列表按 2.3 的合并逻辑拼起来。完整步骤四步:语义输出 argmax;把 stuff 类别写入底图;实例结果按 score 降序排列;逐实例覆盖仍为空白的像素。

def panoptic_postprocess(sem_logits, inst_preds, inst_offset, img_size): sem = sem_logits.argmax(dim=0) # (H, W) panoptic = np.zeros(img_size, dtype=np.int64) # 第一步:写 stuff panoptic[np.isin(sem, stuff_ids)] = sem[np.isin(sem, stuff_ids)] # 第二步:thing 按置信度降序覆盖空白区域 for idx, inst in enumerate( sorted(inst_preds, key=lambda d: d['score'], reverse=True)): m = inst['mask'] & (panoptic == 0) panoptic[m] = inst_offset + idx return panoptic

panoptic == 0表示空白待填充,所以 0 保留给 void 很重要。如果 0 被当作某个 stuff 类别,合并必然冲突,这也是为什么 3.1 节强调 0 留给 unlabeled。输出 panoptic 每个像素一个整数,含类别与实例两层信息,配 color map 直接可视化;保存时注意别被自动转成三通道 RGB 丢了 id 信息。

5. 避坑:图像分割项目最容易翻车的 5 个地方

分割项目翻车往往不是模型问题,是数据约定和后处理问题。下面这 5 条按"现象 → 原因 → 解决"写,都是我实际排过的雷。

5.1 训练正常、指标正常,可视化的背景全变成第一类

现象:loss 在降,mIoU 也还行,结果预测图里大片背景被染成类别 1 的颜色。原因:大多不是模型问题,是类别 id 约定不一致。visualize 脚本里 color map 的第一项写给了类别 0,但模型输出里预测的背景类别其实是 1;或者训练时 ignore_index=255,但标注里 0 被当成背景参与训练。解决:约定三条铁律——unlabeled=0、ignore=255、真实类别从 1 开始;推理后先打印np.unique(pred_ids)看分布再上色;color map 的 key 与类别 id 严格一致。

5.2 小目标全灭:mIoU 虚高要命

现象:mIoU 六七十,看大图挺好,一放大发现人、红绿灯、远处车辆全没了。原因:背景占比太高,交叉熵被大类别主导。mIoU 被几个大类别的高 IoU 拉高,小类别的 IoU 很低但加权平均看不出来。解决:损失函数按 3.3 加 Dice 或给类别加权;数据层面用随机裁剪和复制粘贴增强提高小目标出现频率;评估时直接看混淆矩阵每一行的 IoU,不看平均值。

5.3 推理输出尺寸对不上原图

现象:模型输出的 mask 比原图小一圈,或位置整体偏移几个像素。原因:上采样scale_factor和骨干的实际 stride 不一致;或者align_corners在训练和推理时设置不一致,导致上采用坐标偏移。解决:训练和推理统一用align_corners=False;不要在模型里依赖固定尺寸,推理时显式F.interpolate(logits, size=(H, W), mode='bilinear')对齐原图。

5.4 实例分割的 mask 和 bbox 错位

现象:mask 跑出框外,或者比框明显小一块。原因:mask 是 28x28 的相对坐标,推理时只放大没按 bbox 左上角平移;常见于二段式后处理里 resize 和 translate 写反了。解决:先放大到 bbox 宽高,再按(xmin, ymin)平移回原图;调试时先画 bbox,再叠 mask,错位一眼可见。

5.5 全景分割里实例被 stuff 盖住

现象:车、人从全景图里消失,或边缘像被啃过一样有锯齿。原因:合并顺序反了,stuff 后写把 thing 盖住;或者 mask 二值化阈值太高,边缘像素被判为背景。解决:按 2.3 的顺序——先 stuff 后 thing,thing 按置信度降序;边缘不干净时 mask 阈值用 0.3,或对最终 mask 做一次 3x3 膨胀再覆盖。

6. 评价指标与一个验证技巧:选指标前先选验证图

mIoU 是语义分割的通用指标,每类 IoU 取平均,类别不平衡时更该参考每类单独的值。全景分割用的是 PQ,它把识别质量 SQ 和分割质量 RQ 乘在一起,比单独看 mIoU 更贴近人眼对"这张全景图好不好"的判断。但指标只是结果,我更想说的是一个习惯:验证模型时先选固定验证图,再谈指标。

具体操作是:训练前从验证集挑三张有代表性的图——一张多目标、一张小目标密集、一张边界难的,整个训练周期只用这三张。每 5 个 epoch 保存一次"原图 + 真值 + 预测"拼接图,攒成一个序列。指标再好,这三张图会告诉你模型什么时候开始对细节过拟合、什么时候小目标开始消失,比曲线直观得多。很多调参动作我是看了这种图才定下来的,指标只是事后确认。

另一个验证技巧是看混淆矩阵,但不要只看对角线。对角线给出各类 IoU,真正的错误信息在非对角线:路和路沿互相串、人和柱子互相串,这类系统性混淆光调损失函数没用,要么加对应类别的训练数据,要么合并相近类别,先看清是哪种再动手。分割模型对调参来说本质上还是个黑匣子,能透过可视化看出"它到底把什么认错了",改起来才有方向。

我把"先选验证图、再选指标"这个习惯带到了每个分割项目里,现在回头改模型也更敢下手。希望帮到你。

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

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

双电源自动切换怎么选?二极管、MOS管、专用芯片实测对比

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

作者头像 李华
网站建设 2026/9/28 1:06:59

Python机器学习经典实例代码复现指南

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

作者头像 李华
网站建设 2026/9/28 1:06:56

高通CamX架构解析:Usecase与Pipeline协同机制

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

作者头像 李华
网站建设 2026/9/28 1:06:17

塑料瓶缺陷检测数据集实战:YOLO格式解析与YOLOv8训练避坑指南

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

作者头像 李华
网站建设 2026/9/28 1:04:38

Allegro中PCB定位孔的正确配置方法

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

作者头像 李华
网站建设 2026/9/28 1:04:38

CANoe LIN网络搭建实战:从LDF配置到通信调试的5分钟入门

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

作者头像 李华