news 2026/10/5 9:02:58

多模态情感识别实战:三模态融合与深度神经网络全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多模态情感识别实战:三模态融合与深度神经网络全解析

简介:《基于深度神经网络的多模态情感识别》英文版PDF,是一篇发表于《东南大学学报(英文版)》的学术论文,主要面向深度学习、情感计算、人机交互等领域的研究者、研究生及高年级本科生,旨在解决如何将音频与视频双模态情感信息有效融合并提高识别准确率的问题。论文详细介绍了卷积神经网络在语音频谱和面部表情低级特征提取中的具体用法,循环神经网络对音调变化及时序表情动态的捕捉能力,并提出了混合卷积与循环网络框架,同时讨论了音频信号处理、视频特征选择以及早期融合与晚期融合等多模态融合策略。全文为英文原文,包含基于中文情感数据库的实验过程、准确率对比与结论分析,可供模型复现、实验参考或论文写作时引用。资源仅有1个PDF文件,大小约714KB,体积紧凑,便于全文精读。目前已有233人学习下载,适合作为深度学习相关课程设计、毕业设计和大作业的文献研读材料。

1. 多模态情感识别为什么值得做:光听声音远远不够

不管是英文论文还是工业落地项目里,基于深度神经网络的多模态情感识别已经是人机交互、智能客服质检、车载情感感知等场景的标配方案。我经常碰到有人拿着英文论文来问:单模态的准确率已经不错了,为什么还要折腾三个模态?一个很典型的反直觉结论是:真实场景里的情感从来不是单一信号承载的,语音里说的“干得漂亮”配上不屑的语气和翻白眼的微表情,单看文本或单听音频都会误判成正面,只有把文本、音频、视觉三路信息一起送进深度神经网络,讽刺、愤怒、焦虑这些复合情感才分得清。这篇文章就按我实际做过的方案,把这个方向从数据预处理、模态融合、网络结构、训练参数到推理落地完整拆开讲一遍,适合想做情感计算、智能交互标签系统或复现英文论文实验的工程师直接参照。

2. 三模态选型与数据预处理:文本、音频、视觉各自的坑

2.1 为什么是文本、音频、视觉三路输入

多模态情感识别里,模态数量不是越多越好。公开数据集里最常见的组合是文本-音频-视觉三模态,原因很本质:三个模态分别覆盖情感表达的三个侧面,语义内容靠文本,韵律和语调靠音频,面部表情和头部姿态靠视觉。加上生理信号比如心电、皮电,理论上信息更全,但采集成本高、标注困难,工程上很难落地,我一般不推荐在第一个版本就上。

文本模态负责“说了什么”,常见做法是先用自动语音识别把音频转成文本,再对文本做词向量编码,用预训练语言模型提特征比从头训词向量稳定得多。音频模态负责“怎么说的”,语速、音高、能量起伏这些韵律特征用openSMILE或librosa提取低层描述符就够用。视觉模态负责“表情怎么变”,面部动作单元和表情分类特征可以用openface或mediapipe提取。三条数据源做好预处理和对齐之后,才轮到深度神经网络。

2.2 音频特征提取:从波形到低层描述符

音频特征提取是整个流程里最成熟也最容易出问题的一步。我用librosa比较多,这里给出一个能直接用的代码片段:

import librosa import numpy as np def extract_audio_features(wav_path, sr=16000, n_mfcc=13, max_len=200): # 统一采样率到16kHz,中文语音识别和情感识别的关键参数 y, sr = librosa.load(wav_path, sr=sr) # 提取MFCC,13维通常够用,40维会引入更多噪声 mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=n_mfcc) # 加入一阶差分,捕捉音高和能量的动态变化 delta = librosa.feature.delta(mfcc) delta2 = librosa.feature.delta(mfcc, order=2) features = np.concatenate([mfcc, delta, delta2], axis=0) # 时间步对齐:固定到200帧,不足补零,超过截断 if features.shape[1] < max_len: features = np.pad(features, ((0, 0), (0, max_len - features.shape[1])), mode='constant') else: features = features[:, :max_len] return features.T # 返回形状 (时间步, 特征维度)

这里的核心参数是采样率16kHz,英文论文里很多用44.1kHz,但实际跑下来16kHz在情感识别任务里损失很小,训练速度却能快三分之一。MFCC取13维是经验值,加上差分后变成39维,既能描述短时频谱包络也能捕捉动态变化。固定时间步到200帧对应约5秒音频,比这个更长的音频建议先做切片——情感通常是按句或按段变化的,整段平均会把关键信息稀释掉。

2.3 文本与视觉特征:预训练Embedding与面部动作单元

文本模态的常规操作是加载一个预训练模型做序列编码。我一般用中文场景下的BERT类模型,取最后一层隐状态的平均池化作为整个句子的语义向量。如果你用的英文数据集,直接加载英文预训练模型同理。代码示意如下:

import torch from transformers import AutoTokenizer, AutoModel def extract_text_features(text, model_name="bert-base-chinese", max_len=128): # 先把模型和分词器下载到本地,避免每次实验重复联网 tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) inputs = tokenizer(text, max_length=max_len, padding="max_length", truncation=True, return_tensors="pt") with torch.no_grad(): outputs = model(**inputs) # 取所有token隐状态的均值作为句子向量 sentence_embedding = outputs.last_hidden_state.mean(dim=1) return sentence_embedding.squeeze().numpy()

视觉模态我用mediapipe提取面部动作单元和关键点坐标,它比openface部署简单,效果在近距离会议场景下足够。提取到的特征要按帧记录时间戳,这是后面做模态对齐的基础。文本特征这里有一个很容易踩的坑:如果数据本身就是英文文本,就不要加载中文预训练模型,跨语言嵌入在情感任务里表现会明显下滑。预训练模型的下载路径和版本我会在工程脚本里固定好,避免实验之间特征分布漂移。

2.4 模态对齐:把三条时间线拧到同一根轴上

这是多模态情感识别预处理里最不受重视却最容易翻车的一步。音频采样率是16000Hz,视频帧率通常是25帧每秒或30帧每秒,文本是按词按句出现的,三个模态的时间刻度完全不同。不对齐就做融合,深度神经网络会学到乱七八糟的时间偏移,效果反而不如单模态。

我采用的做法是按话语段对齐:先根据音频端点检测切出每个说话片段,再把文本按时间戳映射到对应片段,最后把视频帧和音频统一重采样到同一个时间分辨率。代码逻辑如下:

def align_modalities(audio_feats, video_feats, text_feats, video_fps=25, target_fps=10): # 将视频帧从原始fps重采样到目标fps old_indices = np.linspace(0, video_feats.shape[0] - 1, int(video_feats.shape[0] / video_fps * target_fps)) new_indices = np.linspace(0, video_feats.shape[0] - 1, video_feats.shape[0]) aligned_video = np.interp(old_indices, new_indices, video_feats, axis=0) # 音频按时间步裁切到和目标fps同样的时间长度 audio_per_frame = audio_feats.shape[0] / video_feats.shape[0] aligned_audio = audio_feats[::int(audio_per_frame)] # 用最短时序长度截断所有模态,防止维度不一致 min_len = min(aligned_audio.shape[0], aligned_video.shape[0], text_feats.shape[0]) return aligned_audio[:min_len], aligned_video[:min_len], text_feats[:min_len]

对齐之后有个验证习惯值得养成:把三个模态的特征打印成热力图,肉眼检查时间轴上的峰谷是否对应同一个情感爆发点。这一步虽然麻烦,但能过滤掉数据管线里百分之八十的时序错位问题。

3. 模态融合策略选型:早期、晚期、混合融合怎么定

3.1 早期融合:特征拼接的特征维度爆炸

早期融合,又叫特征级融合,最直观的做法就是把三个模态的向量拼在一起喂给深度神经网络。比如文本得到768维向量,音频得到39维,视觉得到80维,直接拼接成887维输入。这个方案看起来省事,但实际使用中很快会遇到问题:三个模态的特征分布差异极大,文本embedding的值域和MFCC差出好几个数量级,拼接后网络会不自觉地把数值大的特征当成主导信号,训练出来的模型几乎只靠某一个模态在猜。

更麻烦的是维度爆炸。如果按时间序列特征做拼接而不是句子级拼接,每个时间步都有几百维,叠加网络参数后小数据集很容易过拟合。我见过有人把三模态特征全拼在一起丢给LSTM,结果在验证集上训了二十轮还在震荡。所以早期融合在工业落地里并不受宠,除非你的数据集足够大,而且先做了特征归一化和降维。如下做法会稳很多:先把每个模态各自过一层全连接压缩到64维,再拼接成192维。本质上已经变成混合融合的思路了。

3.2 晚期融合:各模态独立出分再投票

晚期融合也叫决策级融合,每个模态先训练一个独立的情感分类模型,最后把三个模型的输出分数融合成最终结果。这个方案最大的优点是容错性强:某一个模态信号质量差时,其他模态仍然能正常工作。在真实线上环境里,一段视频可能画面模糊、麦克风噪音又大,这时三个模型同时翻车的概率比单模型翻车小。这也是我建议作为系统第一版落地的融合方式,因为三个单模态模型是独立训练的,整个流程简单又直观。

晚期融合最常见的做法是加权求和。每个类别的最终得分等于三个模态概率输出的加权和:

def late_fusion(text_score, audio_score, visual_score, weights=(0.3, 0.3, 0.4)): # text_score/audio_score/visual_score 是各模态输出的类别概率向量 # weights 是各模态的置信度权重,通过验证集搜索得出 final_score = weights[0] * text_score + weights[1] * audio_score + weights[2] * visual_score predicted_class = int(np.argmax(final_score)) return predicted_class, final_score

权重怎么定是个关键问题。常见做法是拿验证集做网格搜索,词搜在一个小范围里。如果数据本身质量均衡,直接用等权重往往也不差。我在做智能客服质检时遇到过一个特殊情况:音频里混着大量背景音乐,音频模态的置信度总是偏小,我把音频权重调到0.15之后整体准确率反而提高了两个点。所以权重不是拍脑袋定的,而是根据每个模态在验证集上的表现单独搜索出来的。

3.3 混合融合:用注意力给模态分配权重

混合融合是目前效果最稳的做法,也是英文论文里最常见的架构。它同时保留模态特异的特征和模态间的交互,并用一个注意力模块动态决定当前样本应该更相信哪个模态。跟晚期融合的固定权重相比,注意力机制能做到根据输入内容实时调整权重——同一个系统里,句子“我没事”配愤怒语调时文本权重低,配平静语调时文本权重高。

我常用的简化注意力的实现是:三个模态经过各自的编码器得到特征向量,然后把三者堆叠成二维矩阵,过一层可学习的注意力打分层,再对三个模态做加权求和,最后送进分类头。代码骨架如下:

import torch.nn as nn import torch.nn.functional as F class AttentionFusion(nn.Module): def __init__(self, embed_dim, num_modalities=3): super().__init__() # 打分网络:把每个模态的特征映射成一个标量权重 self.attn = nn.Sequential( nn.Linear(embed_dim, embed_dim // 2), nn.ReLU(), nn.Linear(embed_dim // 2, 1) ) self.classifier = nn.Linear(embed_dim, num_classes) def forward(self, modality_features): # 输入形状 (batch, num_modalities, embed_dim) scores = self.attn(modality_features).squeeze(-1) # (batch, num_modalities) attn_weights = F.softmax(scores, dim=1) # 加权求和得到融合特征 fused = (modality_features * attn_weights.unsqueeze(-1)).sum(dim=1) return self.classifier(fused)

混合融合的训练难度比晚期融合高,因为它需要三个模态一起反向传播,特征提取部分的收敛节奏不一致时,可能出现一个模态学好了另一个还没收敛。这时候需要用较小的学习率配合分阶段训练:先冻结其他模态只训练融合层,再解开全部参数微调。我实际做下来这个方案能比晚期融合在加权准确率上高出三到五个百分点,代价是训练时间和调参成本多了一倍不止。

4. 深度神经网络结构落实:CNN、双向GRU与注意力模块怎么拼

4.1 卷积神经网络在文本与频谱里的局部建模

把深度神经网络结构落实的时候,卷积神经网络在文本和音频特征上的局部建模能力非常实用。文本的局部n-gram语义、音频频谱上的局部模式,都适合用一维卷积去抓。常见做法是对文本序列或音频特征做一维卷积,再加池化层逐层提炼。比如文本用卷积核大小为3和5的两路卷积分别抓取二元和多元词组特征,音频在时间维上做卷积捕捉语调变化片段。

import torch.nn as nn class ConvFeatureExtractor(nn.Module): def __init__(self, input_dim, conv_channels=64): super().__init__() # 两个不同大小的卷积核并行提取局部特征,类似N-gram效果 self.conv1 = nn.Conv1d(input_dim, conv_channels, kernel_size=3, padding=1) self.conv2 = nn.Conv1d(input_dim, conv_channels, kernel_size=5, padding=2) self.pool = nn.AdaptiveMaxPool1d(1) def forward(self, x): # x形状: (batch, seq_len, input_dim),需要转成通道优先 x = x.transpose(1, 2) h1 = torch.relu(self.conv1(x)) h2 = torch.relu(self.conv2(x)) h = torch.cat([h1, h2], dim=1) h = self.pool(h).squeeze(-1) return h

卷积核大小和通道数是这个模块最值得调的两个参数。经验值是多模态任务用64通道起步,文本特征比音频特征更依赖大卷积核,因为语义模式跨度更大。从英文论文的对比实验来看,卷积核3和5的并联结构是一个性价比很高的基线。需要注意的是,这里的输入必须是已经对齐到同一时间刻度的序列特征,不然卷积的感受野在时间维上是错位的。

4.2 双向GRU与时序建模

情感在时间序列上的变化非常依赖上下文:前半句平铺直叙,后半句突然爆发,这种动态模式是单向时序模型捕捉不到的特征。双向GRU在效果和训练速度之间比其他时序模型均衡,比双向LSTM省参数、收敛快,适合数据量不算特别大的场景。

class BiGRUEncoder(nn.Module): def __init__(self, input_size, hidden_size=128, num_layers=2, dropout=0.3): super().__init__() self.gru = nn.GRU(input_size, hidden_size, num_layers=num_layers, bidirectional=True, dropout=dropout, batch_first=True) self.output_proj = nn.Linear(hidden_size * 2, hidden_size) def forward(self, x): # x形状: (batch, seq_len, input_size) outputs, hidden = self.gru(x) # 取最后一个时间步的双向拼接结果 last_step = outputs[:, -1, :] return torch.relu(self.output_proj(last_step))

双向GRU在这里把每个时间步的过去和未来信息都编码进隐状态,取最后一步的输出作为全片段的语义浓缩。隐层大小128是情感识别任务里常见的起点,如果序列长度特别长比如超过500帧,可以尝试加到256。层数不是越多越好,两层是我在多数数据集上的经验上限,再加深在几千规模的训练集上几乎必过拟合。dropout这里设0.3是针对中等规模数据的推荐值,如果你发现验证集损失震荡加剧,优先提高dropout而不是降低学习率。

4.3 完整模型骨架与训练参数推荐

把特征提取、时序编码和融合模块拼成一个端到端网络,是复现英文论文实验的标准姿势。常见架构是各模态先用卷积提局部特征,再过双向GRU编码时序,然后进注意力融合层,最后过全连接分类头。核心代码如下:

class MultimodalEmotionNet(nn.Module): def __init__(self, audio_dim=39, text_dim=768, visual_dim=80, num_classes=7): super().__init__() self.audio_conv = ConvFeatureExtractor(audio_dim, 64) self.text_conv = ConvFeatureExtractor(text_dim, 64) self.visual_conv = ConvFeatureExtractor(visual_dim, 64) self.audio_gru = BiGRUEncoder(64, 128) self.text_gru = BiGRUEncoder(64, 128) self.visual_gru = BiGRUEncoder(64, 128) self.attention_fusion = AttentionFusion(128) def forward(self, audio, text, visual): a = self.audio_gru(self.audio_conv(audio)) t = self.text_gru(self.text_conv(text)) v = self.visual_gru(self.visual_conv(visual)) features = torch.stack([a, t, v], dim=1) # (batch, 3, hidden) return self.attention_fusion(features)

训练参数上,我一般用AdamW优化器,初始学习率在3e-4到5e-4之间,batch size取32或16视显存而定。学习率调度用余弦退火配合前两步的warmup,损失函数对七分类情感用交叉熵,如果是valence-arousal这种维度模型就换MSE或Huber。显存不够时不要硬上大batch,可以考虑梯度累积,效果稳定且几乎不损失精度。训练过程里面还有一个很关键的细节:音频和视觉特征的序列长度往往比文本长,需要在进入融合层之前确保三路特征维度完全一致,否则torch.stack直接报错。

5. 五种常见故障排查:血泪经验里的翻车现场

5.1 模态缺失:视频没声音,模型直接摆烂

现象是线上推理时某个样本只有文本和画面,没有音频流,模型输出概率分布错乱甚至崩溃。原因是训练时三模态输入总是完整的,模型没有见过模态缺失的情况,推理时缺了一路输入后融合权重计算出的分布完全失真。解决方法是训练阶段做模态随机丢弃,以一定概率把某个模态的输入置零,让注意力融合层学会在缺路时重新分配权重,推理阶段缺失的模态同样补零。我一般把丢弃概率设为0.2,太低学不到鲁棒性,太高会拖累正常样本的收敛。

5.2 标签噪声:同一个样本不同标注者打分差异极大

情感标注天然带有主观性,同一个音频片段有人标愤怒有人标焦虑,原因是情感类别的边界本身模糊,加上标注者不同文化背景差异。英文论文里一般会报告标注一致性指标,实践中如果Kappa系数低于0.4,说明标注质量已经不可信了。解决方法是做标注清洗:把标注者一致率低的样本剔除或改为软标签,用多轮投票而不是一个标注者的结果。这个步骤虽然耗时间,但直接影响模型上限,数据不行再好的网络结构也白搭。

5.3 数据不均衡:生气样本太少,模型永远预测中性

情感数据天然长尾分布,中性类占大头,生气惊讶恐惧这类类别占比往往不到百分之五。如果不做任何处理,模型学到的就是“永远预测中性”的解法,整体准确率看着不低,但实际任务里用户真正关心的正是那些少数的负面情绪。解决办法有两个,最直接的是用类别加权交叉熵,给少类样本更大的损失权重;进阶的做法是Focal Loss,让模型把注意力集中在难分类的样本上,我用下来在少类别上的F1能提升十个百分点左右。数据增强对音频加噪和视频裁剪也能缓解,但效果不如损失函数来得直接。

5.4 特征维度不一致:MFCC、词向量、面部特征量纲差出两个数量级

这个问题的现象是训练初期loss掉得很快,但验证准确率一直原地踏步,查看注意力权重发现模型几乎只盯着数值尺度最大的那个模态。原因是文本预训练embedding的数值范围一般在正负几之间,而MFCC特征动辄几十上百,数值更大的特征天然主导了距离计算和梯度更新。解决方法是在特征进入网络之前做z-score标准化或用BatchNorm前置层,标准化的均值和方差必须从训练集统计,不要把测试集混进来计算,否则会引入数据泄漏。

5.5 融合后反而不如单模态:网络结构越复杂效果越差

这个现象我在实验里遇到过好几次:多模态模型在验证集上打不过最好的单模态模型,融合个寂寞。原因通常是数据量撑不起模型的参数量,三模态网络比单模态网络的参数量大了好几倍,几千条样本根本不够学。解决办法是先检查每个单模态模型的表现,找最强的那个作为基线,然后从晚期融合起步逐步加复杂度。如果数据集规模有限,混合融合不一定优于晚期融合,这时候果断降级方案而不是死磕结构。思路清晰的话,先用轻量网络跑通全流程,再考虑上重模型,方向不能跑偏。

6. 评估指标与部署优化:模型好不如下线稳

6.1 评估指标:看WA、UA、F1和混淆矩阵

多模态情感识别里只盯着整体准确率一定会栽跟头。数据不均衡的背景下,整体准确率会掩盖少数类完全不可用的事实。我一般同时看加权准确率、未加权准确率和宏平均F1三个指标,其中未加权准确率对类别均衡性最敏感,能及时暴露模型是否放弃少数类。混淆矩阵更是排查必备,能直接看出哪些情感类别之间容易被混淆,最常见的混淆对是“愤怒”和“厌恶”,“中性”和“平静”也经常分不开,这往往不是模型问题而是标注定义不清晰。

6.2 推理加速与断点续训:上线前必须做的两件事

深度神经网络模型训练完只是第一步,落地上线前一定要做推理优化。PyTorch模型直接部署会浪费大量性能和显存,常见的做法是先转TorchScript,简单几行代码就能让推理速度提升两到三倍。核心代码:

import torch # 必须在模型所在的同一环境下执行,确保结构和参数一致 model.eval() example_input = { "audio": torch.randn(1, 200, 39), "text": torch.randn(1, 200, 768), "visual": torch.randn(1, 200, 80) } traced_model = torch.jit.trace(model, tuple(example_input.values())) traced_model.save("emotion_model.pt")

另外,长时间训练必须定期保存检查点,我习惯每五个epoch存一次,同时记录最优验证指标对应的权重。哪怕训练中途断掉也能从检查点续跑,不浪费此前几十个小时的计算。这个习惯在调参阶段特别重要,很多次是改了学习率发现效果变差,回滚到最优检查点后重新小步微调,才稳住结果。

6.3 从单模态基线开始:一套能复用的迭代习惯

我这套方案做下来最大的体会是:不要一开始就调三模态融合的大模型,而是先把每个单模态的模型各自调到最好。先让文本、音频、视觉三个单模态模型分别出基线,再把它们组合起来做融合实验。因为多模态融合效果的上限通常由最强的单模态决定,如果单模态基线本身就很差,融合层再努力也救不回来。数据质量永远是第一位,我吃过太多教训在数据没清洗干净时反复调模型参数,最后发现是数据泄漏导致指标虚高。每次实验固定随机种子、固定数据划分、记录训练配置,这些看似枯燥的习惯能让你在复现调整时省下无数时间。希望帮到你,动手之前先把数据管线跑通,再谈模型优化也不迟。

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

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

Embedding 向量嵌入:语义空间、相似度与模型选择

一句话怎样变成一串数字 计算机很容易比较数字&#xff0c;却不能直接理解“如何启动服务”和“服务的启动步骤”表达了相近含义。Embedding 模型解决的正是这个问题&#xff1a;它把文本、图片或音频映射成一组稠密向量&#xff0c;让语义关系能够通过数学距离来比较。 “如…

作者头像 李华
网站建设 2026/10/5 9:01:53

War3 RPG图技能伤害继承英雄属性全攻略:从触发器到伤害公式

玩War3地图编辑器做RPG图的兄弟&#xff0c;应该都遇到过同一个坎&#xff1a;辛辛苦苦做了一个技能&#xff0c;伤害写死是500&#xff0c;英雄从1级练到10级&#xff0c;技能从1级点到5级&#xff0c;面板数值倒是涨了&#xff0c;可打出去的伤害和英雄的属性一点关系都没有。…

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

多AI并行协作实战:任务拆解、上下文隔离与主控调度指南

上个月我手里同时压着四件并行任务&#xff1a;一份灾备方案要出初稿、一套接口测试用例要重写、一个新同事的PR等着审、还有一个老客户在群里问报价。四件事没有一件可以推迟&#xff0c;我一度觉得只有AI能帮我分担这种压力。按我以前的做法&#xff0c;就是硬扛——上午写方…

作者头像 李华
网站建设 2026/10/5 8:59:41

NVIDIA Jetson Xavier NX 刷机、ROS 与深度学习环境配置全攻略

第一次拿到NVIDIA Jetson Xavier NX&#xff0c;很多人的第一反应是“这不就是个带显卡的树莓派嘛&#xff0c;插电就能玩”。等真正动手才发现&#xff0c;刷系统、装ROS、配深度学习环境&#xff0c;每一步都能卡住一大批人。我前后折腾了三四天&#xff0c;踩过刷机识别不到…

作者头像 李华
网站建设 2026/10/5 8:58:44

MRAM工业嵌入式存储方案:MR25H40CDF与TM4C1294 SPI驱动及掉电保护实战

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

作者头像 李华