Y-Net源码学习:双编码器图像分割实战与避坑指南
简介这份资源是Y-Net光声成像重建网络的完整源代码包面向从事医学图像处理、深度学习重建研究的学生与开发者帮助理解如何用神经网络替代传统傅立叶逆变换或有限元方法实现快速、高质量的光声图像重建。压缩包共9个文件以8个Python脚本和1份Markdown文档为主涵盖网络模型定义、训练主程序、数据加载与工具模块整体约14KB体积轻量便于快速阅读与二次开发。目前已有500人学习下载适合作为入门深度学习医学成像的实战参考。通过研读代码读者可以掌握Y-Net在U-Net基础上针对光声成像的架构调整理解卷积、池化、上采样与跳跃连接如何协同捕捉多尺度信息并借助README了解运行流程与参数配置进而迁移到OCT或超声成像等相近任务中也可基于现有模块调整网络结构、复现训练过程或参与开源改进。1. Y-Net 源码学习从双编码器结构到可复现的图像分割方案第一次接触 Y-Net 的人多半是在找医学图像分割或遥感变化检测的轻量方案时撞见它的。它最反直觉的一点是主干不是一条路走到底而是两条编码分支并行最后再拼到一起解码。很多人把它当成 U-Net 的简单变体结果读源码时卡在特征融合那一层不知道两路特征到底怎么对齐、通道怎么裁。这篇笔记就围绕 Y-Net 的源代码学习展开把双编码器结构、融合模块、损失函数和训练配置逐层拆开给出能直接跑通的最小实现。适合已经写过 U-Net、想搞懂多分支分割网络怎么落地的人也适合需要把 Y-Net 改到自己数据集上的从业者。下面所有代码都是可复现的骨架不依赖任何特定仓库你照着填数据路径就能跑。2. Y-Net 的双编码器到底在编什么结构拆解与选型理由Y-Net 的核心设计动机来自一个很实际的问题单一编码器在同时处理纹理细节和语义轮廓时容易顾此失彼。比如医学图像里病灶边界是低频语义但血管纹理是高频细节一条卷积链很难同时保住两者。Y-Net 的做法是开两条编码路径一条走常规的逐层下采样另一条走更浅或不同感受野的路径最后在解码阶段融合。理解这一点读源码时就不会把两条分支当成冗余。2.1 两条编码分支的职责划分常见做法是主分支用标准卷积堆叠每层下采样一次负责提取语义特征辅分支用较少的通道数或不同的卷积核尺寸负责保留空间细节。两条分支的深度可以相同也可以辅分支更浅。源码里通常用两个独立的Encoder类实例化或者用一个nn.ModuleList管理。关键参数是每层的输出通道数主分支一般是[64, 128, 256, 512]辅分支可以是[32, 64, 128, 256]。这样设计的好处是融合时通道数不对等需要一次 1x1 卷积做对齐这也是很多人读源码时第一个卡点。import torch import torch.nn as nn class EncoderBranch(nn.Module): def __init__(self, in_ch, channels): super().__init__() # channels 例如 [64, 128, 256, 512] layers [] for out_ch in channels: layers.append(nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), )) in_ch out_ch self.blocks nn.ModuleList(layers) self.pool nn.MaxPool2d(2) def forward(self, x): feats [] for block in self.blocks: x block(x) feats.append(x) # 保存每层输出用于跳跃连接 x self.pool(x) return feats这段代码里feats列表保存了每个尺度下的特征图解码时要用。pool放在 block 之后意味着特征图先卷积再下采样。参数channels决定了每层通道数主分支和辅分支传不同的列表即可。注意in_ch在循环里被更新保证下一层输入通道匹配。2.2 融合模块的三种常见写法两条分支的特征要在解码前或解码中合并。源码里常见三种融合方式逐元素相加、通道拼接后 1x1 卷积、以及注意力加权融合。逐元素相加要求通道数完全一致所以辅分支通道数必须和主分支对齐这会削弱双分支的灵活性。通道拼接更通用拼接后通道数翻倍再用 1x1 卷积压回目标通道数。注意力加权融合则是给两路特征各算一个权重图按像素加权求和计算量稍大但效果通常更稳。class FusionBlock(nn.Module): def __init__(self, ch_main, ch_aux, out_ch): super().__init__() # 先对齐通道再拼接融合 self.align_main nn.Conv2d(ch_main, out_ch, kernel_size1) self.align_aux nn.Conv2d(ch_aux, out_ch, kernel_size1) self.fuse nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, f_main, f_aux): f_main self.align_main(f_main) f_aux self.align_aux(f_aux) out torch.cat([f_main, f_aux], dim1) # 通道维拼接 return self.fuse(out)align_main和align_aux把两路特征压到同一通道数out_chtorch.cat在 dim1 上拼接fuse里的 1x1 卷积把通道数从out_ch*2降回out_ch。参数out_ch一般取主分支该层的通道数。如果显存紧张可以把out_ch减半但要注意后续解码器的输入通道也要同步改。2.3 解码器如何利用两路跳跃连接解码器每一层要接收三个输入上一层的上采样结果、主编码器对应层的特征、辅编码器对应层的特征。常见做法是先把两路编码特征融合再和上采样结果拼接然后过卷积块。这里有个顺序坑如果先拼接再融合通道数会爆炸先融合再拼接更省显存。源码里通常用一个DecoderBlock封装这个逻辑。class DecoderBlock(nn.Module): def __init__(self, in_ch, skip_main, skip_aux, out_ch): super().__init__() self.up nn.ConvTranspose2d(in_ch, out_ch, kernel_size2, stride2) self.fusion FusionBlock(skip_main, skip_aux, out_ch) self.conv nn.Sequential( nn.Conv2d(out_ch * 2, out_ch, kernel_size3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x, f_main, f_aux): x self.up(x) skip self.fusion(f_main, f_aux) # 如果尺寸不匹配用插值对齐 if x.shape[-2:] ! skip.shape[-2:]: x nn.functional.interpolate(x, sizeskip.shape[-2:], modebilinear, align_cornersFalse) x torch.cat([x, skip], dim1) return self.conv(x)up用转置卷积上采样fusion融合两路跳跃特征interpolate处理尺寸不匹配的边界情况。参数in_ch是上一层解码输出通道skip_main和skip_aux要和编码器对应层通道一致。这个块堆叠四次就能还原到原图分辨率。3. 把 Y-Net 跑起来数据管道、损失函数与训练循环结构清楚之后下一步是让它真正跑起来。Y-Net 源码学习里最容易翻车的不是模型定义而是数据管道和损失函数。分割任务里类别不平衡是常态损失函数选错会让模型直接输出全背景。下面按数据加载、损失、训练循环三步走每步都给可抄的代码。3.1 数据集与增强管道的搭建假设你的数据是图像加掩码的配对格式常见做法是自定义Dataset在__getitem__里同步做图像和掩码的增强。注意几何变换必须同步颜色变换只对图像做。Y-Net 输入尺寸通常是 256x256 或 512x512太大显存吃不消太小细节丢失。import os import cv2 import numpy as np from torch.utils.data import Dataset import albumentations as A class SegDataset(Dataset): def __init__(self, img_dir, mask_dir, size256): self.img_dir img_dir self.mask_dir mask_dir self.names sorted(os.listdir(img_dir)) self.size size # 几何变换同步颜色变换只对图像 self.aug A.Compose([ A.Resize(size, size), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.3), ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(os.path.join(self.img_dir, name)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask_name name.replace(.jpg, .png) mask cv2.imread(os.path.join(self.mask_dir, mask_name), 0) out self.aug(imageimg, maskmask) img out[image].astype(np.float32) / 255.0 mask out[mask].astype(np.int64) img torch.from_numpy(img).permute(2, 0, 1) mask torch.from_numpy(mask) return img, maskA.Compose里几何变换会自动同步图像和掩码RandomBrightnessContrast只影响图像。mask用最近邻插值避免产生中间类别值。参数size要和模型输入一致img_dir和mask_dir里的文件名要能对应上否则会读到空掩码。3.2 损失函数选型Dice 与交叉熵的组合分割任务里纯交叉熵在类别不平衡时会被背景主导纯 Dice 在训练初期梯度不稳定。常见做法是两者加权组合权重一般取 0.5 比 0.5 或 0.3 比 0.7。Y-Net 源码里通常实现一个DiceLoss再和CrossEntropyLoss相加。class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, logits, targets): # logits: [B, C, H, W], targets: [B, H, W] probs torch.softmax(logits, dim1) targets_onehot nn.functional.one_hot(targets, num_classeslogits.shape[1]) targets_onehot targets_onehot.permute(0, 3, 1, 2).float() intersection (probs * targets_onehot).sum(dim(0, 2, 3)) union probs.sum(dim(0, 2, 3)) targets_onehot.sum(dim(0, 2, 3)) dice (2 * intersection self.smooth) / (union self.smooth) return 1 - dice.mean() # 组合损失 ce nn.CrossEntropyLoss() dice DiceLoss() def criterion(logits, targets): return 0.5 * ce(logits, targets) 0.5 * dice(logits, targets)DiceLoss里先做 softmax 再和 one-hot 目标算交并比smooth防止除零。组合损失里权重 0.5 比 0.5 是起点如果验证集 Dice 波动大可以把 Dice 权重降到 0.3。注意targets不能是 one-hot交叉熵要求类别索引。3.3 训练循环与验证指标训练循环里要记录损失和验证集 Dice方便判断是否过拟合。优化器常用 Adam学习率 1e-3 起步配合余弦退火。批大小根据显存调256 尺寸下 8 或 16 都行。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for img, mask in loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() logits model(img) loss criterion(logits, mask) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) torch.no_grad() def validate(model, loader, device): model.eval() dice_sum, count 0, 0 for img, mask in loader: img, mask img.to(device), mask.to(device) logits model(img) pred logits.argmax(dim1) # 只算前景类的 Dice for c in range(1, logits.shape[1]): p (pred c) t (mask c) inter (p t).sum().item() union p.sum().item() t.sum().item() if union 0: dice_sum 2 * inter / union count 1 return dice_sum / max(count, 1)train_one_epoch里注意optimizer.zero_grad()的位置放在前向之前。验证时只算前景类 Dice背景类通常不关心。count防止某些批次没有前景导致除零。学习率调度器可以在每个 epoch 后调用scheduler.step()。4. Y-Net 源码学习中的避坑与排查清单这一章是我自己踩过的坑按现象、原因、解决三段写。很多问题不是模型本身而是数据或配置的细节。4.1 融合层通道数不匹配导致报错现象运行时报RuntimeError: Given groups1, weight of size [64, 128, 3, 3], expected input[1, 96, 64, 64] to have 128 channels。原因主分支和辅分支在某一层的输出通道数不一致融合时没有对齐。解决在FusionBlock里强制用 1x1 卷积把两路通道压到同一值或者检查编码器配置里两条分支的channels列表是否在对应层一致。我一般会在融合前打印两路特征形状确认无误再往下走。4.2 验证集 Dice 一直不涨现象训练损失在降但验证 Dice 卡在 0.3 左右。原因常见是数据增强过度或者损失函数权重偏向交叉熵导致模型只学背景。解决先把增强关掉跑一遍确认模型能过拟合小数据集再把 Dice 权重提到 0.7 试试。另外检查掩码的类别值是否从 0 开始连续如果有 255 这种未映射值交叉熵会报错或学偏。4.3 显存溢出但批大小已经调到 1现象CUDA out of memory即使 batch size 设为 1。原因Y-Net 双编码器参数量接近单编码器的两倍加上融合模块的中间特征显存占用比 U-Net 高不少。解决把输入尺寸从 512 降到 256或者把辅分支通道数减半。还可以用混合精度训练torch.cuda.amp能省三成左右显存。如果还不行检查是否有特征图在循环里被意外保留比如feats列表没释放。4.4 转置卷积产生棋盘伪影现象预测掩码边缘有规律性的网格状伪影。原因ConvTranspose2d的 stride 和 kernel size 不匹配时会出现棋盘效应。解决把上采样换成nn.Upsample(modebilinear)加一个 3x3 卷积或者确保kernel_size2, stride2这种整除组合。源码里如果用了kernel_size3, stride2输出尺寸会不规整需要额外 padding。4.5 多卡训练时 BatchNorm 统计量不同步现象单卡验证正常多卡训练时验证指标崩掉。原因nn.BatchNorm2d默认只统计当前卡上的批次多卡时统计量有偏。解决把 BatchNorm 换成SyncBatchNorm用nn.SyncBatchNorm.convert_sync_batchnorm(model)转换。或者改用 GroupNorm对小批量更稳。这个坑在双分支结构里更明显因为每张卡上的有效样本更少。5. 进阶技巧用注意力融合替换固定权重并验证收益基础版 Y-Net 的融合是固定权重的拼接或相加进阶做法是让网络自己学两路特征的权重。常见实现是加一个通道注意力或空间注意力模块对两路特征各算一个 0 到 1 的权重图再加权求和。这样在细节和语义冲突的区域网络能自动偏向更可靠的那一路。class AttentionFusion(nn.Module): def __init__(self, ch): super().__init__() # 用全局平均池化生成通道注意力 self.fc nn.Sequential( nn.Linear(ch * 2, ch // 2), nn.ReLU(inplaceTrue), nn.Linear(ch // 2, 2), nn.Softmax(dim1), ) def forward(self, f_main, f_aux): b, c, h, w f_main.shape # 拼接后做全局池化 feat torch.cat([f_main, f_aux], dim1) gap nn.functional.adaptive_avg_pool2d(feat, 1).view(b, -1) weights self.fc(gap) # [B, 2] w_main weights[:, 0].view(b, 1, 1, 1) w_aux weights[:, 1].view(b, 1, 1, 1) return f_main * w_main f_aux * w_auxAttentionFusion先把两路特征拼接做全局平均池化再用两层全连接生成两个权重softmax 保证和为 1。w_main和w_aux广播到空间维后加权求和。参数ch是单路特征通道数fc中间层取ch//2是经验值太大容易过拟合。这个模块替换掉原来的FusionBlock即可其余结构不变。验证收益的方法在同一个数据集上跑两组实验一组用固定融合一组用注意力融合其他超参完全一致。看验证集 Dice 和收敛 epoch 数。我的经验是注意力融合在前景占比低于 10% 的数据上提升明显能涨 2 到 4 个点但在前景占比高的数据上差异不大反而多了一点计算量。所以值不值得上先看你的类别分布。还有一个实用技巧是冻结辅分支的前几层只训练主分支和融合模块。这样在数据量少的时候能防止辅分支过拟合训练也更稳。具体做法是在EncoderBranch的blocks里对前两个 block 设requires_gradFalse优化器里过滤掉这些参数。这个习惯帮我省过不少调参时间尤其是在标注样本只有几百张的时候。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

OOA-XGBoost:鱼鹰算法优化XGBoost分类预测的Matlab实现

OOA-XGBoost:鱼鹰算法优化XGBoost分类预测的Matlab实现

简介:OOA-XGBoost鱼鹰算法优化XGBoost分类预测Matlab完整源码与数据,定位为面向高校理工科学生的智能优化与分类预测一体化项目模板,适合课程设计、期末大作业、毕业设计及科研算法对比。压缩包共18个文件,包含8个m源码文件&#…

2026/10/12 18:47:10 阅读更多 →
连续邮资问题:动态规划与回溯搜索的工程实践

连续邮资问题:动态规划与回溯搜索的工程实践

1. 连续邮资问题到底是什么,为什么值得花时间搞懂连续邮资问题(Consecutive Postage Problem)是组合数学与动态规划交叉领域里一个非常经典的模型。它的核心描述其实很朴素:给定 n 种不同面值的邮票,每种面值最多允许贴…

2026/10/12 18:46:08 阅读更多 →
Java项目整合Groovy脚本引擎:依赖冲突、内存管理与性能优化实战

Java项目整合Groovy脚本引擎:依赖冲突、内存管理与性能优化实战

1. 为什么要在Java项目里引入Groovy第一次在Java项目里引入Groovy,很多人是因为一个很具体的需求:业务规则老在变,每次改都要重新编译、打包、上线,流程太重。比如风控规则、促销计算、报表公式这类场景,产品经理今天加…

2026/10/12 18:46:08 阅读更多 →

最新新闻

冈某云音乐榜单爬虫实战:从抓包分析到批量保存(含代码对照)

冈某云音乐榜单爬虫实战:从抓包分析到批量保存(含代码对照)

前言很多音乐爱好者都有收藏歌单的习惯,但手动一首首下载不仅繁琐,而且效率极低。本文将详细介绍如何利用 Python 编写一个冈某云音乐榜单爬虫。我们将跳过复杂的逆向工程,利用外链播放器接口的特性,实现从榜单解析到歌曲 ID 提取…

2026/10/13 10:22:52 阅读更多 →
【STM32C5教程】(3)GPIO 按键输入实验

【STM32C5教程】(3)GPIO 按键输入实验

欢迎关注 youcansxidian【嵌入式软件AI编程】专栏 【STM32C5教程】(1)STM32C5系列介绍 【STM32C5教程】(2)STM32CubeMX2 安装与STM32C5闪灯 【STM32C5教程】(3)GPIO 按键输入实验 1. 项目简介 1.1 实验目标…

2026/10/13 10:22:52 阅读更多 →
RK3588与Jetson双芯协同:边缘AI智能盒子硬件架构与推理性能实测

RK3588与Jetson双芯协同:边缘AI智能盒子硬件架构与推理性能实测

1. 从一块开发板到一台“智能盒子”:我为什么盯上了这个组合前阵子有个做边缘计算的朋友甩给我一个链接,说“你看看这个,RK3588加Jetson的AI盒子,有点意思”。我当时第一反应是:这俩放一起?RK3588是瑞芯微的…

2026/10/13 10:22:52 阅读更多 →
从‘补全‘到‘任务托管‘:编码Agent进入云端化+多子代理阶段,开发者要不要把整个任务交出去?

从‘补全‘到‘任务托管‘:编码Agent进入云端化+多子代理阶段,开发者要不要把整个任务交出去?

从“补全”到“任务托管”:编码 Agent 进入云端化 多子代理阶段,开发者要不要把整个任务交出去? 一、正在发生的变化:Agent 不再只是“更聪明的补全” 如果你还在把编码 Agent 理解成“自动补全的高级版”,那么这个…

2026/10/13 10:22:52 阅读更多 →
Android RTSP播放Demo完整拆解:选型、参数配置与高频避坑指南

Android RTSP播放Demo完整拆解:选型、参数配置与高频避坑指南

简介:一份可直接运行的 Android RTSP 播放示例工程,面向需要在直播、网络摄像头或视频监控等场景中接入实时流媒体的 Android 开发者,既适合入门学习也适合快速原型验证;其核心通过 MediaPlayer 加载 rtsp:// 地址,完整…

2026/10/13 10:22:52 阅读更多 →
元宝    LeetCode 332. Reconstruct Itinerary Java Implement

元宝 LeetCode 332. Reconstruct Itinerary Java Implement

Core idea: Hierholzer’s algorithm for an Eulerian path. A naive lexical greedy walk can get stuck (e.g. “JFK→KUL” dead-ends when “JFK→NRT→JFK→KUL” is the answer). The fix: keep walking greedily, but only append each airport on the way back up — d…

2026/10/13 10:21:51 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/13 2:54:56 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/12 18:11:17 阅读更多 →