news 2026/9/29 4:48:35

Android掌纹识别轻量化部署:RandomForest模型实战全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Android掌纹识别轻量化部署:RandomForest模型实战全解析

掌纹识别这几年在移动端的热度一直在涨,尤其在不方便摘口罩、不方便用指纹的场景下,掌纹作为独立生物特征的优势就体现出来了。它不像人脸那样对光线敏感,也不像指纹那样容易受磨损影响,而且用手机摄像头就能采集,不需要额外的硬件成本。但很多团队在真正落地时卡在了一个点上:深度学习模型虽好,放到 Android 上要么模型文件太大,要么推理速度撑不住中低端机型。我自己的做法是,把模型换成了 RandomForest,配合一套从图像到特征再到 Android 端轻量化推理的完整链路,在保证识别准确率的条件下,把 APK 体积和推理延迟都压到了很低的水平。这篇文章就把整个项目的思路、训练过程和部署细节完整拆开。

这篇内容适合正在做移动端生物识别、或者兜兜转转用了很多重型模型却始终无法落地的团队,也适合刚开始接触模型部署、想弄清楚“训练一套模型到手机上跑起来”到底要走多少路的开发者。我会把每一段关键代码、每一个参数选择的理由都讲清楚,尽量让你看完就能直接照着实施。

1. 项目定位与技术选型:为什么掌纹识别偏偏选中 RandomForest

1.1 掌纹识别在移动端的应用价值

掌纹识别的采集方式很简单,摄像头拍一张手掌照片就能完成,用户心理接受度也高——毕竟人人都有手掌,不像人脸涉及隐私顾虑。但掌纹纹理的信息密度其实非常高,主线和细小的脊线分布、屈肌纹、乳突纹,组合在一起可以形成相当高的区分度,这是它作为生物特征的基础。

在移动端做掌纹识别,核心挑战从来不是识别方法本身,而是设备碎片化。Android 机型从几百块到上万块都有,摄像头规格、处理器性能差距悬殊。一套识别方案如果只能在旗舰机上流畅运行,实用价值就会大打折扣。这就要求模型层面尽可能轻,预测阶段的计算量必须小到中低端 CPU 也能扛得住。

1.2 为什么不用深度学习,而选 RandomForest

很多人看到图像识别就默认要上 CNN、MobileNet 甚至更大规模的网络。但掌纹识别有自己的特殊性:我们可以先对图像做较强的预处理和特征提取,把“图像问题”转换成“表格问题”。一旦特征工程做得足够好,RandomForest 这种传统机器学习模型的表达能力完全可以胜任识别任务。

我选择 RandomForest 的最直接原因是部署成本。一个深度分类模型打包到 Android,大小动辄十几 MB,就算量化后也还有几 MB。而一个训练好的 RandomForest,只要树的数量控制在合理范围内,用 PMML 或 ONNX 格式导出后常常只有几百 KB 甚至更小。另一个关键优势是推理速度快。RandomForest 的预测过程就是一堆 if-else 判断的叠加,不需要矩阵乘法和卷积运算,单次推理在中低端 Android CPU 上能做到 5 毫秒以内,实际体感是接近瞬时出结果。

我并不是说深度学习方案不好,而是对于掌纹这种“可控环境、可控采集、单一目标”的场景,传统机器学习路线在工程性价比上明显更高。如果你的需求是复杂场景下的通用图像识别,那该用深度学习还是用深度学习。这里强调的是方案匹配,不是技术鄙视链。

2. 数据准备与特征工程:掌纹图像如何变成模型能懂的数字

2.1 掌纹图像采集与 ROI 提取

训练 RandomForest 之前,必须先解决数据从哪来的问题。公开的掌纹数据集不少,比如 PolyU 掌纹数据集、CASIA 掌纹数据集,都可以作为起步资源。但真实落地时,我强烈建议在目标设备上自采一批数据,因为公开数据集的拍摄距离、光照、背景和你最终上线时的环境大概率不一致。

采集阶段要固定几个关键变量:

  • 拍摄距离:手掌在画面中占据的比例要尽量一致
  • 光照条件:最好用均匀的室内光,避免强逆光
  • 手掌姿态:五指自然张开,掌心正对镜头

拿到原始图像后,第一步是 ROI(Region of Interest)提取。掌纹识别的 ROI 通常是掌心区域,也就是屈肌纹最丰富的那一块。我的做法是先用肤色检测分割出手的轮廓,基于轮廓计算手掌的几何中心,再以两个关键点(通常是中指和无名指之间的指璞位置)为基准,裁出固定尺寸的掌心区域。这一步看起来简单,但实际坑很多,最常见的就是不同人的手大小差异,导致 ROI 裁出来比例失调。我的解决策略是先把图像统一缩放到固定高度,再基于比例坐标裁剪,保证所有人的 ROI 在空间尺度上对齐。

2.2 特征工程:让 RandomForest 学会“看”掌纹

RandomForest 不能直接吃图像像素,我们需要把 ROI 区域的纹理信息转换成一组有区分度的数值特征。我在这套方案里最终用了三类特征的组合,每一类都覆盖了掌纹信息的不同侧面。

第一类是 LBP(Local Binary Pattern)纹理特征。LBP 的核心逻辑很简单:遍历图像中每个像素,把它和周围邻域像素逐个比较,大于中心像素记为 1,小于记为 0,从而组成一个二进制序列,再统计成直方图。LBP 对光照变化有天然的抗干扰能力,因为它做的是局部相对比较而不是绝对值比较。我在项目中把 ROI 图像划分成 8x8 的网格,每个网格单独计算 LBP 直方图再拼接,这样既保留了局部纹路分布信息,又比全图直方图多了一层空间约束。

第二类是 Gabor 滤波响应特征。Gabor 滤波器在纹理分析里很有地位,它的本质是一个带有方向和频率选择性的带通滤波器。掌纹纹路在不同方向上呈现不同的频率特性,用 4 个方向和 2 个尺度共 8 个滤波器对 ROI 做卷积,每个滤波结果计算均值和标准差,得到 16 维统计特征。这一步能补上 LBP 在纹路方向感知上的不足。

第三类是几何拓扑特征。包括主屈肌纹的走向角度、主线之间的间距分布、波纹密度等。这些特征需要借助图像增强和二值化来提取,过程类似传统的指静脉识别。几何特征在多指姿态微变时稳定性不如前两类,但在同一组数据内能显著提升区分度。

三类特征拼接后,每张掌纹图像最终表示为 500 维左右的向量。这个维度对 RandomForest 来说非常友好,既不会因为维度过高拖慢训练,又保留足够的判别信息。

2.3 数据增强与样本均衡

传统机器学习模型的增强思路和深度学习不太一样。深度学习可以随意旋转、平移、缩放图像,因为 CNN 对这些变换有先天的鲁棒性。但 RandomForest 不直接处理像素,它看到的是特征向量,所以增强必须在特征层面做,或者在做完增强后重新提取特征。

我在项目中用了两类增强手段。

第一类是图像层面的轻度扰动:对 ROI 图像做小角度旋转(正负 5 度以内)、轻微缩放(0.98 到 1.02 倍)、高斯噪声添加。每次扰动后重新走一遍特征提取流程,相当于变相扩增了训练样本。注意旋转角度不能太大,否则 ROI 的纹路结构会被破坏,反而变成错误样本。

第二类是特征层面的合成增强。对同一类别的真实验本,随机抽取两个样本的特征向量,按随机权重加权融合,生成一个虚拟样本。这个思路类似于深度学习中常用的 Mixup,在传统机器学习里同样有效。由于掌纹数据天然带有身份类别标签,这种合成方式生成的样本仍然保持在原类别的特征空间范围内,不会破坏标签语义。

样本均衡方面也需要特别注意。如果某些身份采集了 30 张图、另一些身份只采了 8 张图,模型会倾向于过拟合图像多的类别。我最终的训练集把每个身份统一控制在 20 张,不足的通过增强补足,超过的随机抽取子集。整体样本规模在 200 人左右,也就是全量约 4000 张图片,对 RandomForest 的训练耗时来说完全可控。

3. RandomForest 模型训练与调参实战

3.1 数据集划分与评估方案设计

在做了特征工程后,特征矩阵的形状是 (N, D),其中 N 是样本总数,D 是特征维度。为了不引入数据泄漏,划分数据集时必须注意一个原则:同一个身份的所有样本要么全在训练集,要么全在测试集,不能混在一起随机打乱。否则模型会很容易“记住”某个身份的特征分布,测试时遇到同一身份的不同照片就能直接命中,得到的准确率会虚高。

我的划分方案是:按身份分层随机抽样,80% 的身份进入训练集,20% 的身份进入测试集,每个身份对应的所有样本作为一个整体搬到对应集合。这样做能模拟真实的用户场景——上线后遇到的是从未见过的人,而不是训练集里出现过的人。

3.2 模型训练代码与关键参数说明

特征准备好后,直接用 scikit-learn 训练 RandomForest,代码非常简洁。以下是我实际使用的训练脚本核心部分:

import numpy as np import joblib from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import StratifiedShuffleSplit from sklearn.metrics import accuracy_score, confusion_matrix # 加载特征 # features.shape = (4000, 512), labels.shape = (4000,) features = np.load("palm_features.npy") labels = np.load("palm_labels.npy") # 按身份划分训练/测试集 # identity_ids 记录每个样本对应的身份编号 identity_ids = np.load("identity_ids.npy") unique_ids = np.unique(identity_ids) split = StratifiedShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_indices = [] test_indices = [] for train_id_idx, test_id_idx in split.split(unique_ids, unique_ids): train_ids = unique_ids[train_id_idx] test_ids = unique_ids[test_id_idx] train_indices = np.where(np.isin(identity_ids, train_ids))[0] test_indices = np.where(np.isin(identity_ids, test_ids))[0] clf = RandomForestClassifier( n_estimators=200, max_depth=18, min_samples_split=4, min_samples_leaf=2, max_features='sqrt', class_weight='balanced', n_jobs=-1, random_state=42 ) clf.fit(features[train_indices], labels[train_indices]) # 评估 test_pred = clf.predict(features[test_indices]) acc = accuracy_score(labels[test_indices], test_pred) print(f"Test accuracy: {acc:.4f}") # 保存模型 joblib.dump(clf, "palm_rf_model.joblib")

我选这几个参数不是随手填的,背后都有实际考虑在。n_estimators=200是因为我试过 50、100、200、500 几档,准确率在 200 棵时已经收敛,继续加树只会增加模型体积和推理时间,收益很小。max_depth=18是限制单棵树的深度,防过拟合的同时控制模型文件大小。min_samples_split=4和min_samples_leaf=2同样是正则化手段,叶子节点样本数下限可以避免模型为单样本特判。max_features='sqrt'是经典的随机性策略,每次分裂只随机考虑一部分特征,让每棵树之间的差异性更大,集成效果更好。

3.3 超参数调优与评估结果

我做完第一轮基准训练后,测试集准确率大约在 92% 左右。这个成绩在掌纹识别里不算差,但还有提升空间。接下来我用网格搜索配合交叉验证,对主要超参数做了一轮细化调优。

from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier param_grid = { 'n_estimators': [100, 200, 300], 'max_depth': [12, 15, 18, 22], 'min_samples_split': [2, 4, 6], 'min_samples_leaf': [1, 2, 4] } grid_search = GridSearchCV( RandomForestClassifier(max_features='sqrt', class_weight='balanced', n_jobs=-1, random_state=42), param_grid, cv=5, scoring='accuracy', verbose=1 ) grid_search.fit(features[train_indices], labels[train_indices]) print(grid_search.best_params_)

网格搜索的结果是:最优参数落在n_estimators=250、max_depth=16、min_samples_split=4、min_samples_leaf=2。这个组合比基准模型在测试集上提高了约 1.8 个百分点,最终准确率稳定在 94% 左右。需要说明的是,掌纹识别的评估指标不能只看整体准确率,还要关注每个类别的召回率。因为如果某一个身份经常被认错,实际使用中就是反复验证失败。所以我在评估时额外打印了每个类别的 confusion matrix,确认没有明显的类别级偏差。

另外我也对比过 SVM 和 KNN 的效果。SVM 的准确率能达到 93%,但模型导出后不支持增量更新,超参数调优也更繁琐。KNN 虽然不需要训练,但预测时要计算全量距离,放到 Android 上意味着要把所有训练样本都打进 APK,体积会到几十 MB。RandomForest 在模型体积、推理速度、准确率三者之间是最平衡的选择。

4. Android 端轻量化推理部署全流程

4.1 模型导出与格式转换

模型训练完毕,接下来是最容易出问题的环节——把 Python 世界的模型搬到 Android。直接用joblib.dump保存的模型无法在 Android 上加载,需要转换成通用格式。我尝试过两条路线:PMML 和 ONNX。

PMML 是一条主线。PMML 是一种基于 XML 的模型描述标准,可以把 RandomForest 的树结构完整表示出来。我用sklearn2pmml库将模型导出为 PMML 格式。文件大小大约 800KB,对 APK 来说完全可以接受。Android 端用jpmml-android这个库来加载和推理,它的原理是先解析 PMML 文件,在 Java 层重建决策树结构,然后执行预测。这条路线的好处是纯 Java 实现,不需要引入 JNI 和原生库,集成难度低,调试也方便。

ONNX 是另一条路线。RandomForest 可以转换成 ONNX 格式,Android 端用onnxruntime-mobile加载。ONNX Runtime Mobile 的体积比完整版小很多,实测 APK 增量大约 5MB。推理性能上,ONNX Runtime 有多线程优化和指令集加速,理论上比纯 Java 的 PMML 解析方式更快。但我实测下来,300 棵树以内的 RandomForest 在两种方案上的推理时间差距很小,都在毫秒级。考虑到 PMML 方案不引入额外原生库、包体积更小,我最终选定了 PMML 作为主力部署格式。

4.2 Android 工程集成与推理代码

Android 端集成 PMML 模型的步骤不复杂,但有几个细节要小心。

先把jpmml-android的依赖加到build.gradle:

dependencies { implementation 'org.jpmml:jpmml-android:1.0.0' }

然后把palm_rf_model.pmml文件放到app/src/main/assets目录下。加载模型的代码写在一个单例工具类里,避免每次推理都重新加载。

public class PalmModel { private static PalmModel instance; private ModelEvaluator<?> evaluator; private PalmModel(Context context) { try { InputStream is = context.getAssets().open("palm_rf_model.pmml"); PMML pmml = new PMML(is); evaluator = new ModelEvaluatorFactory().newModelEvaluator(pmml); } catch (Exception e) { Log.e("PalmModel", "load model failed", e); } } public static synchronized PalmModel getInstance(Context context) { if (instance == null) { instance = new PalmModel(context.getApplicationContext()); } return instance; } public float predict(float[] features) { Map<String, Object> input = new HashMap<>(); for (int i = 0; i < features.length; i++) { input.put("f" + i, features[i]); } Map<String, Object> result = evaluator.evaluate(input); Object value = result.get("probability"); // 概率值解析为类别 return parsePrediction(value); } }

需要注意,PMML 文件里输入特征的名字必须和inputMap 里的 key 完全一致。我在 Python 导出模型时把特征列命名成了f0、f1、...、f511,那 Java 端就必须按同样的规则填 key。这个坑我踩过,名字对不上时不会直接崩,而是返回一个默认结果,排查起来非常浪费时间。

4.3 量化与性能优化

PMML 模型本身是浮点运算,但 RandomForest 的推理本质上是比较大小,所以精度的敏感度远低于深度网络。不过我仍然做了一层优化:把float计算改成int。具体做法是对特征提取阶段输出的浮点值统一乘一个缩放因子(比如 1000),转成整数后输入模型。由于决策树分裂阈值也是预先算好的浮点数,对应地也缩放取整。这种改动在理论上会引入轻微的精度损失,实测准确率下降了不到 0.3 个百分点,换来的是推理时间进一步缩短以及避免浮点性能不稳定的问题。

Android 端的性能优化还要考虑另一个层面:特征提取是在 Java/Kotlin 层做,还是在 JNI 层做。掌纹图像从相机预览帧到 LBP 直方图,中间涉及大量的像素遍历和数学运算。如果全部在 Java 层做,中端机型可能耗时 80 到 120 毫秒,虽然也能接受,但叠加相机预览和 ROI 提取的时间,整个识别链路的延迟会逼近 200 毫秒,体感上不够跟手。

我的优化方案是把特征提取部分用 C++ 重写,在 JNI 层完成,推理部分继续用 PMML 的 Java 实现。这样每个环节都用最适合的方式处理:

  • 相机预览帧通过ImageReader获取 YUV 数据
  • 通过 JNI 调用 C++ 函数完成 ROI 裁剪、LBP 计算、Gabor 响应统计
  • 返回float[]特征数组给 Java 层
  • Java 层调用 PMML 模型完成分类

实测优化后完整识别链路延迟从 200 毫秒降到 60 毫秒以内,其中特征提取约 50 毫秒,PMML 推理约 5 毫秒。识别准确率和离线测试基本一致,这说明我在特征工程阶段选择的特征在真实场景下是稳定的。

5. 部署踩坑实录:三个典型的翻车现场

5.1 数据格式不一致导致推理结果全线漂移

第一次把模型接到 Android 上做真机测试时,发现所有人脸掌纹都被识别成同一个人。我原本以为模型导出出了问题,反复验证 PC 端测试集准确率没问题。排查到最后才发现是特征归一化的问题。

我在训练前对特征做了标准化,也就是减均值除以标准差,均值和标准差是拿训练集统计出来的。Python 端推理时,输入样本也会走同样的标准化。但 Android 端我当时觉得“模型已经训好了,不需要标准化也能用”,就没有实现这个步骤。结果 RandomForest 对特征尺度是有敏感度的,尤其当某些特征列的方差很大时,树分裂点完全乱掉。

这个问题的解决方法是把标准化参数(均值和标准差向量)打进模型文件,或者在 Android 端保存一份 JSON,推理前先对特征向量做同样的标准化。我最终选择了后者,因为这样做不需要重训练模型,只要确保输入侧和训练侧完全一致就行。建议每一位做部署的同学,先列一个“输入数据预处理一致性检查清单”,模型训练时每做一步预处理,部署端必须完整复现。

5.2 内存抖动与 GC 频繁触发

在低端 Android 设备上测试时,我注意到识别过程中有明显的卡顿,尤其是连续识别多张掌纹时,界面掉帧严重。用 Android Profiler 抓了一下内存,发现 GC 频率很高,原因是每次识别都在循环里创建了大量的临时对象,比如逐像素计算时的Integer包装对象、特征 Map 里的Stringkey、以及HashMap本身。

优化分两步。第一步是复用对象,把特征 Map 做成成员变量,每次推理只更新 value 而不新建 Map。第二步是特征提取移到 JNI 后,像素操作完全在 C++ 层完成,不再产生 Java 临时对象。优化后 GC 频率明显下降,连续识别场景下的帧率稳定在 30fps 以上。这个问题的根因不是模型推理慢,而是 Java/Kotlin 层的对象管理不当,属于典型的工程实现问题。如果你也用 PMML 推理,记得把evaluate调用尽量次数少、对象尽量复用。

5.3 模型文件放在 assets 里居然也会损坏

这个坑最诡异。我有一版模型在 PC 端反复测试没问题,打包进 APK 后,部分机型加载时报解析异常。起初以为代码写错了,后来把 APK 里的 PMML 文件解压出来和源文件对比才发现,assets 目录下的压缩方式导致文件被 AAPT 压缩处理,某些机型在运行时解压出现字节不一致。

解决办法是在build.gradle中显式声明模型的noCompress属性:

android { aaptOptions { noCompress "pmml" } }

加了这一行之后,模型文件会以不压缩的方式直接打包进 APK,运行时读取的是原始字节,问题不再出现。这个经验也适用于其他类型的模型文件,比如.onnx、.tflite、.bin,在 Android 上部署时建议都加上noCompress配置,别让打包环节成为不稳定因素。

另一个相关经验是,在真机发布前,务必多机型覆盖测试。我测过不同的 Android 版本和厂商 ROM,发现部分 ROM 对 assets 读取的处理确实有差异,市面上主流的文件提供方也各有自己的问题。但noCompress是最基础的一层保险,先把这个配好,再谈其他。

6. 一点实战心法:模型再轻,也得守住特征侧的一致性

整套方案做下来,我个人最深刻的体会是:最难的环节不是模型训练,也不是 Android 部署,而是保证训练环境和推理环境的数据处理完全一致。RandomForest 这个模型本身很皮实,训练快、解释性强、部署成本低,可它并不会自动帮你修正特征分布上的偏差。

特征提取阶段里面的细节密得很,比如说 ROI 裁剪方式,Python 端用 OpenCV 裁的,Android 端如果换成了自研图像库,哪怕裁出来的图像视觉上几乎一样,LBP 直方图的数值也会有微小差异。这种差异在单张图上可能察觉不到,但累积到几百个特征维度上,就会实实在在影响最终分类结果。

所以我的建议是:如果你打算把这个方案落地,优先把特征提取封装成一个跨端一致的模块,最好直接用同一套 C++ 代码在 PC 端和 Android 端编译运行。这样训练和推理天然走同一条逻辑,彻底避免两套实现带来的特征偏移问题。我在项目后半段就是这么做的,之后跨端一致性相关的故障基本消失。

最后再多说一句:在掌纹识别这个场景里,RandomForest 并不是唯一可用的模型,但它在模型大小、推理速度、可解释性、集成难度这几个维度上的综合表现确实很适合 Android 端。如果你手头的需求也是“单一目标的图像分类 + 移动端部署”,不妨沿着这条路线先跑通一个最小闭环,再逐步优化特征侧的能力。这套链路的价值,不在于用多前沿的算法,而在于每一环都稳扎稳打、可预期、可复现。

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

GitHub热门AI工具盘点:10款实用AI工具速览与TaoToken统一接入配置

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

作者头像 李华
网站建设 2026/9/29 4:47:26

Hindsight:Chrome取证利器,解析历史记录与SQLite数据

1. Hindsight 是什么&#xff1a;不是我“事后才发现”&#xff0c;而是 Chrome 取证的一把刀先说明一下&#xff0c;这里的 Hindsight 并不是心理学里那个“后见之明”的概念&#xff0c;而是一个开源的数字取证工具。名字叫 Hindsight&#xff0c;我猜作者多少有点自嘲的意思…

作者头像 李华
网站建设 2026/9/29 4:46:29

Android极致签名校验:Java+NDK+Binder四层防御架构

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

作者头像 李华
网站建设 2026/9/29 4:44:48

GPU性能实时监控全攻略:从nvidia-smi到DCGM集群实践

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

作者头像 李华
网站建设 2026/9/29 4:44:39

Cadence Virtuoso中VCVS行为级建模:从原理到仿真避坑指南

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

作者头像 李华
网站建设 2026/9/29 4:42:56

化妆品仓储库存管理系统:JSP毕设从建表到WAR部署全流程

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

作者头像 李华