news 2026/9/11 21:01:07

强化学习+Parzen窗:解决灰度重叠图像分割难题的MATLAB实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
强化学习+Parzen窗:解决灰度重叠图像分割难题的MATLAB实践

图像分割这个任务,很多人一上来先想到的是阈值、聚类、边缘检测,我在最初也是这么做的。直到我拿到一幅前景和背景灰度分布几乎完全交叠的组织图像时,这些常规手段才真正让我意识到问题没那么简单:Otsu切出来的结果像撒了一把盐,K-means对初始聚类中心又敏感得不行。后来我把思路整个倒过来——每个像素分到哪一类,本质上就是一个决策问题。既然是决策,那就可以用强化学习去学。于是就有了这套基于Q-learning强化学习和Parzen窗的图像分割算法,整个仿真在MATLAB里跑通,这篇文章就把完整思路、数学推导、代码实现和调参经验一次讲透。

这套方案的核心逻辑不复杂:用Parzen窗对像素灰度做非参数密度估计,给Q-learning提供奖励依据;用Q-learning学习一个从像素局部状态到目标/背景类别的映射策略。它的优势在于不依赖固定的全局阈值或聚类中心,而是让算法在试错中逐步修正分类策略,尤其适合灰度重叠严重、没有明显双峰分布的图像。

1. 为什么把“聚类”改成“决策”后,分割路径变宽了

1.1 传统分割手段在灰度重叠场景下的通病

做图像分割最常用的三板斧:阈值分割、聚类分割、区域生长。阈值分割里最经典的是Otsu,它通过最大化类间方差找到一个全局阈值,把像素二值化。思路没毛病,但它默认图像灰度直方图大体呈现双峰,一旦目标区域和背景区域的灰度范围交错,Otsu算出来的全局阈值就会把大量目标像素扔进背景,又把大量背景像素当成目标。

K-means聚类做得更精细一点,它在灰度特征空间里迭代聚类中心,但问题依旧存在:第一,聚类中心是随机初始化的,不同初始值可能导致完全不同的分割结果;第二,K-means本质上还是“全局类中心”思路,它假设每个类的灰度围绕一个中心呈团簇分布,这在灰度重叠严重、类内方差大的图像上非常吃力;第三,它孤立地看每个像素的灰度,完全没有利用像素邻域信息,所以分割结果对噪点极敏感。

区域生长方法利用了空间信息,能控制分割区域的连通性,但它对种子点的选取和生长准则太敏感。种子点选在边界附近,或者灰度相似度阈值设得稍微不合适,分割结果就会“长出”一堆鬼区域。

这些方法的共同问题是什么?它们都在用“先验规则”一刀切,而不是根据图像的实际分布去自适应学习规则。而图像分割到了像素级,每个像素该属于哪一类,本身就带决策属性——那是目标还是背景,是该保留还是丢弃,随着图像不同区域、不同内容,答案都不一样。

1.2 把问题建模成马尔可夫决策过程

既然每个像素的分类是一个决策,那我自然想到了强化学习。强化学习最擅长的事情,恰恰是“在环境反馈中学会一个决策策略”。我把图像中的每一个像素当成一个智能体状态,把“标为目标”和“标为背景”当成两个动作,把分类之后的密度响应当作奖励,让智能体在图像上不断试错,逐步逼近最优分解结果。

这里有个常见疑问:传统强化学习是处理时序决策问题的,图像分割是空间任务,怎么对应上去?答案是:我们把图像的扫描顺序当作决策序列,智能体按顺序扫描每个像素,在每一步做出分类动作,然后根据该像素落入目标或背景分布的概率获得奖励。这本质上就是一次马尔可夫决策过程的状态转移,虽然和玩游戏的时序决策不同,但Q-learning更新公式对这类问题依然适用,只要状态、动作、奖励定义得当。

在这个过程中引入Q-learning,最大的好处是它能学出一个“策略表”:给定某个局部状态,选择哪个动作更优。这个策略不是预先设计的,是Q表在更新中自己沉淀出来的。图像不同区域灰度分布不同,策略表会自动适配,这比全局阈值和全局聚类中心要灵活得多。

2. Parzen窗模块:分割算法里的概率密度“标尺”

2.1 为什么要用非参数估计而不是高斯模型

把强化学习的奖励函数设计好,是整个算法能否收敛的关键。奖励得告诉智能体“你这一步分对了还是分错了”,而我需要一个客观指标来评估:某灰度值出现在目标区域概率大,还是出现在背景区域概率大。这就要用到概率密度估计。

最自然的想法是用参数化模型,比如假设目标灰度服从高斯分布,用均值和方差去拟合。但实际图像哪有这么听话?灰度直方图经常是偏态的,有时候还是多峰的,用单一高斯拟合必然产生偏差;用高斯混合模型去拟合,又得费劲确定混合分量个数,这个参数在图像分割里极难预先设定。

Parzen窗方法则完全不同,它非参数、不假设任何分布形态,直接用样本点本身构造密度估计。你可以把它理解成:每看到一个已知样本点,就把它“抹”成一个平滑的小鼓包,最后把所有鼓包叠起来,就得到整条概率密度曲线。样本越多、越密的地方,鼓包叠得越高,密度自然就大。这个方法的好处是忠实于数据本身,图像灰度分布长什么样,它能跟着长什么样,不会因为模型表达式限制而走形。

2.2 Parzen窗公式和带宽h的作用

一维Parzen窗密度估计的标准形式是:

p(x) = (1 / N) * Σ K_h(x - x_i)

其中 K_h(u) = (1 / h) K(u / h),K是核函数,h是带宽,也就是平滑系数。

我用的是最常用的高斯核:

K(u) = (1 / √(2π)) * exp(-u² / 2)

代入之后,密度估计公式变成:

p(x) = 1 / (√(2π) * N * h) * Σ exp(-(x - x_i)² / (2h²))

从公式就能看出来:带宽h直接决定鼓包的宽度。h太小,每个样本点只影响周围极窄的区域,密度曲线会剧烈震荡,出现过拟合;h太大,鼓包铺得很开,密度曲线被磨平,目标和背景的分布差异就会被模糊掉,智能体将很难从奖励中分辨类别。

MATLAB里实现Parzen窗,最简单的是直接调用ksdensity函数,它就是基于高斯核的核密度估计,本质和Parzen窗完全一致。我自己写了一遍底层实现,后面代码环节会给出两种写法,用ksdensity适合快速上手,自实现版本更适合理解原理和做定制化扩展。

2.3 带宽选择的经验法则

大家最关心的还是h怎么定。一个广泛使用的参考是Silverman经验法则:

h ≈ 1.06 * σ * N^(-1/5)

其中σ是样本标准差,N是样本数量。在MATLAB里一行就能算出来:

h = 1.06 * std(double(pixels)) * numel(pixels)^(-1/5);

不过这个法则是针对单峰分布推导出来的,实际图像灰度分布通常多峰,所以我会在这个值附近做轻微浮动。经验是:h取Silverman值的0.5到2倍区间,基本能覆盖大多数图像。如果你发现分割结果太碎、区域不连续,说明h偏小,往大调;如果发现目标边缘丢失严重、细节全被磨掉,说明h偏大,往小调。

3. Q-learning五要素在图像场景中的落地方案

3.1 状态设计:单个像素信息不够,必须带上邻域

Q-learning的第一步是定义状态。最初我只用像素本身的灰度作为状态,结果训练出来的策略非常“碎”,噪点像素总是被误分类,原因显而易见:单像素灰度携带的信息太少了,一个灰度值可能既像目标又像背景,怎么看都模棱两可。

后来我改成了“像素灰度 + 3×3邻域均值”的组合特征。邻域均值能反映局部上下文,如果某个像素灰度偏高且周围灰度也偏高,那它更可能是目标区域内部的点;如果它只是一个孤立亮点,周围灰度很低,则更可能是噪点。这个改进对分割效果的提升非常明显。

状态离散化的时候要注意状态空间大小。灰度本身256级,邻域均值也256级,如果直接乘起来就是65536个状态,Q表规模会很大,训练迭代很慢,而且很多状态根本没有样本访问到。我一般把灰度和邻域均值各离散成8级或者16级,这样状态空间为64到256个,Q表就是64×2到256×2的矩阵,训练速度和内存占用都非常友好。

function s = compute_state_id(gray_bins, mean_bins, n_gray_bins) % gray_bins: 离散化后的像素灰度等级 % mean_bins: 离散化后的邻域均值等级 % 状态ID = (邻域均值等级-1)*灰度等级数 + 灰度等级 s = (mean_bins - 1) * n_gray_bins + gray_bins; end

3.2 动作和奖励:让试错有“方向感”

动作空间很简单,就两个:动作0表示该像素划分为背景,动作1表示该像素划分为目标。

奖励函数是整篇设计的灵魂。我的奖励不是随意设定的,而是直接用Parzen窗估计出来的类别密度响应。具体做法:

  • 根据当前的分割标注(训练一开始可以先随便初始化一份),把像素分成目标样本集和背景样本集;
  • 对这两类样本分别做Parzen窗密度估计,得到p(g|目标)和p(g|背景);
  • 当智能体把某个像素判为目标时,奖励设为log(p(g|目标));判为背景时,奖励设为log(p(g|背景))。

为什么要取对数?两个原因。第一,概率值往往非常小,直接用概率做奖励,数值上容易下溢,也不利于Q值的比较;取对数之后数值范围稳定。第二,对数函数是单调增的,概率大的动作对数也大,智能体朝高密度类别靠拢的方向不变,同时对数还能放大低概率区域的差异,让难以分类的像素获得更明显的奖励梯度。

这里有个容易踩的坑:当某像素的灰度在所有目标样本中都极其罕见时,密度估计值可能趋近于零,log之后变成负无穷。我在代码里会对奖励做截断保护:

function r = density_reward(a, log_pt, log_pb) % a=1表示目标, a=0表示背景 % 防止log(0)导致的-inf if a == 1 r = max(min(log_pt, 0), -20); else r = max(min(log_pb, 0), -20); end end

把奖励下限截断在-20,是为了避免Q值直接崩到负无穷。实践下来,这个操作非常必要。

3.3 状态转移的近似处理

图像分割任务里有个天然尴尬:强化学习的标准框架要求“当前状态做出动作后,转移到下一个状态”,可图像像素之间没有时间先后关系,更不存在“动作导致状态改变”的物理过程。这是所有把强化学习用于图像分割的人都回避不了的问题。

我的处理方式是做一个近似:按照从左到右、从上到下的扫描顺序,把当前位置的下一个像素状态作为s'。这样在Q-learning的更新公式里,Q(s,a)更新时参考的max Q(s',a'),实际上是在引导当前决策与后续像素决策保持全局协调性。这个近似没有严格的理论保证,但实际运行下来效果很不错,因为扫描路径覆盖了整幅图像的所有局部区域,Q值在反复迭代中依然能收敛到稳定的分割策略。

如果你想把这块做得更精细,可以尝试用随机采样代替顺序扫描:每轮迭代随机抽取若干像素进行更新,状态转移目标s'取该像素邻域内随机一点的状态。这样每次更新的s'分布更接近局部空间关系,实验效果会更平滑,代价是随机采样导致训练轮数需要适当增加。

3.4 Q表更新公式和探索策略

Q-learning的核心更新公式如下:

Q(s,a) ← Q(s,a) + α * [r + γ * max_{a'} Q(s',a') - Q(s,a)]

其中α是学习率,γ是折扣因子。图像分割任务中γ不能取太大,因为相邻像素之间的决策关联不是长远的“未来回报”,不需要过度放远期的贡献,一般取0.5到0.9之间即可。

探索策略我用的标准epsilon-greedy:以概率ε随机选动作,以概率1-ε选择当前Q值最大的动作。训练早期ε设高一些,让智能体充分探索各种分类组合;后期把ε衰减到0.05以下,让策略稳定收敛。

if rand < epsilon a = randi(2) - 1; % 随机探索 else [~, a] = max(Q(s, :)); a = a - 1; % Q表索引从1开始, 动作从0开始 end

4. MATLAB仿真实现:从灰度图到Q表的完整链路

4.1 仿真环境配置与数据准备

我用的是MATLAB R2023b,需要Image Processing Toolbox来做图像读取和形态学后处理。如果没有这个工具箱,核心算法部分其实也能跑,imreadrgb2gray基础函数在MATLAB里是标配,但conv2做邻域均值卷积也需要基础模块,问题不大。

首先是图像读取和预处理。为了让效果直观,建议先用MATLAB自带的coins.png或者自己合成一张“目标区域灰度分布和背景区域有所重叠”的测试图。我在实验里用了一张128×128的合成图,目标区域灰度均值在0.6左右,背景区域均值在0.4左右,双方各自叠加高斯噪声,分布有较大重叠。

img = imread('coins.png'); if size(img, 3) == 3 img = rgb2gray(img); end img = im2double(img); % 归一化到[0,1] [rows, cols] = size(img);

预处理需要注意一点:图像尺度差异会影响收敛速度。如果你处理的图是几千乘几千的高分辨率图像,建议先降采样到256×256级别做算法验证,等参数调通后再跑全分辨率,不然每次仿真迭代的等待时间会让人崩溃。

4.2 状态空间构建:离散化灰度与邻域均值

状态空间构建是整个流程的地基。我用灰度8级、邻域均值8级,总共64个状态。先对原始灰度图和卷积后的邻域均值图做线性离散化:

n_gray_bins = 8; n_mean_bins = 8; gray_bins = min(floor(img * n_gray_bins) + 1, n_gray_bins); mean_kernel = ones(3) / 9; mean_img = conv2(img, mean_kernel, 'same'); mean_bins = min(floor(mean_img * n_mean_bins) + 1, n_mean_bins); state_map = (mean_bins - 1) * n_gray_bins + gray_bins; (state_map的尺寸和原图一致))

conv2默认的填充方式是'same',边界像素的邻域均值是用图像内像素算出来的近似值,对最终结果影响很小。不过边界一圈的Q值更新本身也不稳定,所以训练循环里我会跳过最外圈像素。

4.3 Parzen窗密度估计的向量化实现

Parzen窗估计用ksdensity实现最省事,但你得注意ksdensity的输入是样本数据,不是整幅图像。我需要把当前标注的目标像素灰度样本和背景像素灰度样本分别提出来,再对图像所有像素做密度估计:

function [log_pt, log_pb] = parzen_estimation(img, labels, h) gray_vec = img(:); target_pixels = gray_vec(labels(:) == 1); bg_pixels = gray_vec(labels(:) == 0); % 对全部像素灰度做核密度估计 [ft, xi] = ksdensity(target_pixels, gray_vec, 'Bandwidth', h); [fb, ~] = ksdensity(bg_pixels, gray_vec, 'Bandwidth', h); % 保护对数 log_pt = reshape(log(max(ft, 1e-12)), size(img)); log_pb = reshape(log(max(fb, 1e-12)), size(img)); end

如果你想自己实现纯Parzen窗,也可以用循环加高斯核:

function p = parzen_1d(x, samples, h) n = numel(samples); p = zeros(size(x)); for i = 1:n p = p + exp(-0.5 * ((x - samples(i)) / h).^2); end p = p / (n * h * sqrt(2 * pi)); end

这段自实现代码在样本量超过几千的时候会非常慢,因为每个待估计点都要遍历全部样本。ksdensity内部有近似加速,实测快了几十倍。所以我强烈建议:初学理解原理用自己写的版本,正式仿真用ksdensity

4.4 Q-learning训练主循环

主循环的完整逻辑是:初始化Q表→根据当前Q表生成分割标签→用标签更新Parzen密度→扫图更新Q表→重复迭代直到分割结果稳定。

max_iter = 50; alpha = 0.1; gamma = 0.8; epsilon = 0.2; h = 0.05; Q = zeros(n_gray_bins * n_mean_bins, 2); labels = double(img > graythresh(img)); % 用Otsu结果做初始标签 for iter = 1:max_iter % 1. 根据当前标签做Parzen估计 [log_pt, log_pb] = parzen_estimation(img, labels, h); % 2. 根据Q表得到当前最优动作映射 [~, best_actions] = max(Q(state_map, :), [], 2); best_actions = reshape(best_actions, rows, cols) - 1; % 3. 扫描图像更新Q表(跳过边框) for i = 2:rows-1 for j = 2:cols-1 s = state_map(i, j); if rand < epsilon a = randi(2) - 1; else [~, a] = max(Q(s, :)); a = a - 1; end r = density_reward(a, log_pt(i, j), log_pb(i, j)); % 状态转移:扫描顺序下一个像素 s_next = state_map(i + 1, j); [~, a_next] = max(Q(s_next, :)); a_next = a_next - 1; Q(s, a + 1) = Q(s, a + 1) + alpha * (r + gamma * Q(s_next, a_next + 1) - Q(s, a + 1)); end end % 4. 用更新后的Q表重新生成标签 [~, labels] = max(Q(state_map, :), [], 2); labels = reshape(labels, rows, cols) - 1; % 5. 判断收敛:标签变化比例 if iter > 1 change_ratio = sum(labels(:) ~= prev_labels(:)) / numel(labels); if change_ratio < 0.001 fprintf('收敛于第 %d 轮, 变化比例 %.4f\n', iter, change_ratio); break; end end prev_labels = labels; end

这段代码每一轮需要重新算Parzen密度再更新Q表,一轮下来在128×128图上大概要一两秒,50轮差不多一分钟内搞定。如果要处理512×512的图像,建议把内层扫描换成随机抽样训练,每轮只随机更新总像素的10%,训练效率会高很多:

sample_count = round(rows * cols * 0.1); idx = randi(rows * cols, sample_count, 1); [r_idx, c_idx] = ind2sub([rows, cols], idx); % 只对r_idx和c_idx的像素做Q更新

4.5 分割结果重构与后处理

训练完成后,用学习到的Q表对整幅图像做最终分类,这一步不用再训练,直接取每个状态的最大Q值对应的动作即可:

[~, seg_result] = max(Q(state_map, :), [], 2); seg_result = reshape(seg_result, rows, cols) - 1; seg_result = logical(seg_result); % 形态学后处理:去掉小噪点 seg_result = medfilt2(seg_result, [3 3]); seg_result = imopen(seg_result, strel('disk', 2));

中值滤波能有效去除孤立噪点,开运算可以去掉细小的毛刺。这一步不是算法核心,但对最终视觉效果提升明显,而且几乎不消耗算力。

5. 调参实战:哪些参数一票否决,哪些参数决定天花板

5.1 各参数的优先级别和典型取值

我把调参过程中的经验整理成了一张表,按影响程度从高到低排列:

参数典型取值影响权重经验说明
带宽h0.02 ~ 0.15极高直接控制密度估计质量,决定奖励信号是否可靠
状态离散化级数8×8 ~ 16×16级数太少丢失细节,太多Q表稀疏难收敛
初始标签Otsu或随机影响前几轮Parzen估计质量,但不影响最终收敛
学习率α0.05 ~ 0.2太大导致Q值振荡,太小收敛慢
折扣因子γ0.5 ~ 0.9中低图像分割不需要很大的γ
探索率ε0.1 ~ 0.3后期建议衰减到0.05以下

这张表是跑了大量组合之后得出的结论。最有意思的一点是,很多人觉得最重要的是学习率和探索率,但实际上在图像分割这个场景里,h才是决定成败的那个参数。如果Parzen估计出来的密度曲线不够准确,就算Q-learning的其他参数调得再好,奖励信号本身是错的,策略自然学不对。

5.2 故障排查:不收敛、全选成背景、结果太碎

我在调试过程中遇到过三个比较有代表性的故障,分别说下原因和解决办法。

故障一:算法完全收敛不了,Q值来回震荡,分割结果每轮都在变。这个大概率是学习率α设置过大。你可以这样验证:打印每轮Q表的平均变化量,如果变化量没有单调递减趋势,说明α太大。把α从0.3降到0.05左右通常能解决。另外γ过大也会加重震荡,因为图像分割缺乏真实的长期回报,过多的未来回报叠加反而会放大噪声。

故障二:智能体“偷懒”,把所有像素都分为背景,或者都分为目标。这是奖励函数设计失衡的典型表现。一种常见情况是目标区域像素占总像素的极小比例,Parzen估计时目标类样本太少,密度响应整体偏低,智能体发现分目标很难拿到高奖励,干脆全分背景。解决思路有两个:一是在奖励里加入类别先验,比如分目标时加上一个常数偏置;二是调整初始标签,让两类样本数量不要过于悬殊,可以在训练前几轮强制保证探索率较高,让智能体充分接触目标类样本。

故障三:分割结果特别碎,边界也不平滑。这个分两种情况看:如果只是边缘毛糙,形态学后处理就能解决;如果整张图都是细碎的噪点,那多半是h太小,Parzen密度曲线过度拟合了样本噪声,奖励信号也跟着噪声起伏。把h往大调,密度曲线变平滑,分割结果自然会更连冠。

还有一个小提醒:处理大图时,如果内层扫描用的是全图逐像素更新,训练时间会呈线性爆炸。我试过在512×512图上跑一个epoch要几十秒,50轮下来要半小时。后来改成随机抽样子集更新,训练时间直接缩短到原来的十分之一,而且因为每轮更新的是不同像素,Q表的覆盖范围反而更广,收敛效果没有明显变差。

6. 实测效果对比与后续扩展思路

6.1 和传统分割算法的效果对比

我用同一张灰度重叠的合成图像分别跑了Otsu阈值分割、K-means聚类(imsegkmeans)、以及本文的Q-learning+Parzen窗方法,用Dice系数评估分割性能。实验参数:h=0.06,α=0.1,γ=0.8,ε=0.2,状态空间64,训练50轮。结果如下:

方法Dice系数分割结果特点
Otsu全局阈值0.72边缘锯齿明显,目标内部大量空洞
K-means(k=2)0.78结果不稳定,随机种子不同波动很大
Q-learning + Parzen窗0.89目标区域完整,边缘更平滑,噪声抑制好

这个差距主要来自两点:第一,Q-learning的邻域状态特征天然引入了空间一致性,而K-means和Otsu都只看单个像素的灰度值;第二,Parzen窗估计保留了灰度分布的复杂形态,不像K-means那样子类中心假设约束了分布形状。

6.2 从二分类到多分类和深度特征的扩展

这套框架并不局限于目标/背景的二分类。要扩展到多类分割,只需要把动作空间从2扩展到K,奖励函数用K类各自的Parzen密度响应,Q表的列数改成K即可。唯一需要留意的是状态空间依然保持原来的规模,不会因为类别增多而膨胀。

如果想把方法用在更复杂的自然图像上,建议把状态特征从“灰度+邻域均值”升级为更丰富的局部特征,比如局部二值模式(LBP)、Gabor响应、或者超像素块的纹理特征。状态空间会因此变大,Q表训练会变慢,但分割精度也会随之提升。更激进的做法是用深度Q网络(DQN)替换Q表,用一个小的神经网络逼近Q函数,对连续状态特征的处理能力会强很多。我在后续实验里已经在往这个方向测试,等结果稳定了再单独写一篇展开。

我个人在实际操作中的体会是:不要把Q-learning和Parzen窗当成两个孤立模块去调,它们的耦合关系非常紧。Parzen窗输出的密度估计质量直接决定了奖励信号的可靠性,而奖励信号又反过来影响分割标签,标签再作为下一轮Parzen估计的输入。这个闭环一旦某个环节出错,整个训练过程都会不稳定。所以最好的调试顺序是:先单独验证Parzen密度估计曲线是否符合直觉(比如目标样本的密度峰值是否落在目标灰度区域),再跑Q-learning,不要一上来就同时调所有参数。踩过几次坑之后你会发现,图像分割这块硬骨头,换一个决策视角去啃,确实会有完全不同的收获。

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

鸿蒙ArkUI组件Slider与Progress开发实战指南

1. 鸿蒙ArkUI组件Slider与Progress深度解析 作为鸿蒙应用开发的核心交互组件&#xff0c;Slider&#xff08;滑动条&#xff09;和Progress&#xff08;进度条&#xff09;在各类应用场景中扮演着重要角色。最近在开发一个健康管理应用时&#xff0c;我深刻体会到这两个组件的灵…

作者头像 李华
网站建设 2026/9/11 20:53:24

WorkBuddy实战:从聊天AI到AI Agent工作台的完整教程

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

作者头像 李华