news 2026/9/12 18:09:01

基于OpenCV级联分类器的中国象棋棋子识别系统实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于OpenCV级联分类器的中国象棋棋子识别系统实战

简介:基于OpenCV级联分类器的中国象棋棋子识别系统,是一套面向高校计算机专业学生课程设计或期末大作业的完整实践项目。系统依托Python与OpenCV视觉库,通过级联分类器实现红黑棋子的自动化检测,覆盖数据集准备、模型训练与识别测试全流程。压缩包共19个文件,包含3个Python源码模块、训练与测试数据集、模型备份以及JPG测试结果图,整体大小31.39MB,结构清晰便于直接部署调参。已有76人学习浏览,适合正在做图像处理、机器学习方向课题的学生参考。资源内提供可直接运行的代码与说明文档,并有分色识别效果图帮助验证算法,是一套可快速上手、具备完整闭环的实战范例。

1. 基于OpenCV级联分类器的中国象棋棋子识别系统:先想清检测和识别是两件事

拿到“基于OpenCV级联分类器的中国象棋棋子识别系统”这个题目,最容易踩的第一个坑,是以为级联分类器能直接分清楚“车马炮”。OpenCV的CascadeClassifier落地时只做二分类,它返回的是一串“可能是棋子”的矩形框,真正回答“这是红车还是黑马”的是它后面的另一个识别层。所以这个项目按工作流其实拆成三块:正负样本数据集、用OpenCV工具链训练出来的级联模型、以及Python推理管道里负责精读棋面文字的字符识别模块。数据集干净与否决定训练能不能收敛,级联模型决定你能找到多少个棋子,字符层决定最后报给用户的棋子名对不对。适合看这篇的人,是想用传统图像处理而不是深度学习来做棋子定位的学生,要在嵌入式或低配环境里跑棋盘识别的工程师,以及刚跑通OpenCV但还不知道怎么组织训练样本的初级开发。

2. 数据准备:用OpenCV生成中国象棋棋子正样本与负样本

2.1 正样本怎么来:先用HoughCircles自动切棋子圆盘

正样本的源头通常是一张棋盘照片或一段对弈视频。人工把棋子一个个裁出来可行,但效率太低。更常用的做法是借“棋子是圆形”这个先验,先用cv2.HoughCircles做一次预标注,把棋盘上每个棋子变成圆心加半径,再按半径乘系数切出正方形ROI,存成待标注正样本。

import cv2 import numpy as np img = cv2.imread("source_board.jpg") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.medianBlur(gray, 5) # 中值滤波压掉棋盘纹路噪声 circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1.2, # 累加器分辨率与输入图像分辨率的比值,1.1~1.3常用 minDist=30, # 相邻两个棋子中心的最小像素距离,按棋盘格距调 param1=80, # Canny高阈值,光线差时往下调 param2=70, # 圆心累加器阈值,调大可以减少假圆 minRadius=18, maxRadius=55 ) if circles is not None: for x, y, r in np.round(circles[0]).astype(int): side = int(r * 2.2) # 半径乘2.2,让字符带一点外圈留白 x0, y0 = x - side // 2, y - side // 2 roi = img[y0:y0 + side, x0:x0 + side] cv2.imwrite(f"dataset/positive/{x}_{y}.jpg", roi)

这段代码的逻辑是:先转灰度并做中值滤波,防止棋盘交叉线干扰边缘检测;HoughCircles把棋子变成圆,再把每个圆按外接正方形裁出来。为什么半径要乘2.2而不是直接取直径?中国象棋棋子外沿有一圈深色边框,识别字符时真正重要的是内圈文字,留一点边距能让后续二值化不把文字和边缘黏在一起。参数字典里最值得调的是param2,它控制“圆”的置信度,调高了会漏掉被手指遮挡的棋子,调低了会把棋盘上的圆形纹理也当成棋子,实际调试时建议先固定minDistminRadius,只动param2一个参数。

切出来的ROI还需要人工筛一遍,把误检、粘连、裁偏的图删掉,剩下干净样本。这一步不能省,级联分类器对噪声样本的容忍度远低于深度学习模型。

2.2 生成vec:用positives.txt加opencv_createsamples

正样本整理好后,需要把图片转成OpenCV训练用的vec文件。最稳妥的方式是写一个positives.txt标注文件,每行格式是“图片路径 目标数量 x y w h”:

dataset/positive/100_200.jpg 1 0 0 64 64 dataset/positive/320_150.jpg 1 2 3 60 60 dataset/positive/450_80.jpg 1 0 0 68 68

然后执行:

opencv_createsamples -info dataset/positives.txt -vec dataset/positives.vec -num 900 -w 32 -h 32

-w-h是级联训练时的样本归一化尺寸,整个流程里必须始终一致,后面opencv_traincascade用32×32,这里就不能写成64×64。-num是生成多少个正样本,通常小于等于标注文件里的目标总数。如果觉得样本量不够,可以给opencv_createsamples追加-maxxangle 0.3 -maxyangle 0.3 -maxzangle 0.6,让它从原始ROI随机生成旋转、缩放和透视变换的增强样本。中国象棋棋子会被随手拧到任意方向,所以-maxzangle尽量给到0.5以上,否则训练出来的模型对转过的棋子完全不买账。生成后建议马上看一眼:

opencv_createsamples -vec dataset/positives.vec -w 32 -h 32 -show

弹出的窗口会逐张显示vec里的样本。这一步主要检查两件事:一是字符是否完整出现在画面里,二是背景是否露出太多边缘。整个正样本的重要程度高于任何参数,很多项目训练失败,回头看都是vec里混进了半张手指或半个棋盘。

2.3 负样本组成与bg.txt组织

负样本是级联分类器里更容易被忽略、但决定误报率的一半。负样本并不需要和正样本一样归一化到32×32,训练器会从每张图里随机裁窗口,所以你只需要提供一张张原始图。建议的负样本构成比例可以参考下面这张表:

负样本内容作用建议数量
空棋盘与棋盘网格交叉区压住网格纹理误报800张以上
棋子周围的木质/塑料桌面压住材质误报800张以上
对局照片里的手、纸、笔提高泛化能力500张以上
含汉字印刷体或书法字防止单个汉字被当成棋子300张以上

总负样本数建议2500张起步,越多越好,但不要用清一色的纯色图充数。纯色负样本会让训练阶段学不到“什么不是棋子”,级联在真实棋盘上一跑就全是误检。负样本路径列表写进dataset/bg.txt,每行一个相对路径:

ls dataset/negative/*.jpg > dataset/bg.txt

注意:bg.txt里的路径在Windows下不要把反斜杠直接丢给opencv_traincascade,统一转成正斜杠,否则OpenCV 4.x会把路径整个读成空文件,报错信息还不明显。

3. 训练级联:Haar与LBP怎么选,opencv_traincascade的关键参数

3.1 特征类型权衡:为什么中国象棋棋子优先选LBP

级联分类器支持的特征类型不止一种,选错特征会让训练时间差出好几倍。OpenCV里能直接在opencv_traincascade里用的主要有Haar、LBP和HOG,它们是从不同角度描述图像纹理的:

特征类型计算方式训练耗时光照敏感度适合场景
Haar相邻矩形区域像素和之差较慢中等边缘清晰的刚性物体
LBP局部像素与中心像素比大小纹理物体、低分辨率图像
HOG统计梯度方向直方图更慢中等行人与大尺寸目标

中国象棋棋子的结构是“外圈深色环、中间浅色底、内部深色汉字”,这是个强纹理目标而不是强边缘目标。LBP做的是局部邻域比较,对光照变化和棋盘木纹干扰的容忍度远高于Haar,训练速度也快不少。同样的样本量,LBP在普通CPU上训练15个stage大概几十分钟,Haar可能要多等一倍时间。另外LBP对低分辨率非常友好,32×32的样本里汉字笔画还能辨认,用Haar反而更容易被噪声带偏。

但这同时带来一个代价:LBP对字符间的细微差异不敏感,比如“士”和“仕”这种字形相近的棋子,它区分不了。所以“级联负责找到棋子、后处理负责分清字符”是个天然合理的分工,别指望把识别也压进级联里。

3.2 opencv_traincascade训练命令与参数卡

数据齐了以后,训练命令长这样:

opencv_traincascade \ -data model/chess_cascade \ -vec dataset/positives.vec \ -bg dataset/bg.txt \ -numPos 900 \ -numNeg 2400 \ -numStages 15 \ -featureType LBP \ -w 32 -h 32 \ -minHitRate 0.995 \ -maxFalseAlarmRate 0.5 \ -precalcValBufSize 2048 \ -precalcIdxBufSize 2048

逐项说明一下这些参数在项目里的含义和调整方向:

参数含义建议值调整方式
-numPos每轮参与训练的正样本数vec总数的90%训练中断且提示样本不够时降到85%
-numNeg每轮参与的负样本数2400~3000误检偏高就往上加
-numStages级联阶段总数14~16误检还很明显的就加到20
-minHitRate单阶段最低检测率0.995低于0.99容易漏棋
-maxFalseAlarmRate单阶段最大误检率0.5降到0.4能更准但要更多stage
-featureType特征类型LBPHaar慢,但可作对照组
-w -h样本尺寸32 32不要大于40,否则训练很慢

-numPos这个参数最容易让人困惑。它不要求等于vec里的样本总数,因为训练器每完成一个stage都会从vec里重新取样,某些样本会被重复消耗,设得太顶会在后期报Train dataset for temp stage can not be filled。我一般直接把-numPos设成vec实际样本数的85%~90%,宁可少训一点,也别让训练跑两小时后崩掉。-minHitRate则决定了单个stage“必须保住多少正样本”,0.995意味着每个阶段允许漏掉千分之五的棋子,对棋盘这种目标明确的小物体足够用了。

3.3 训练中断的典型原因与对策

opencv_traincascade报错信息一向很“节俭”,不查日志基本看不出问题。实际项目里最常见的失败模式就是下表的几类:

报错现场真实原因处理办法
Could not open .../bg.txt路径分隔符或文件编码统一ASCII路径、正斜杠
Assertion failed (elements_read == 1)vec文件损坏或尺寸不一致重新生成vec,检查-w -h
Train dataset for temp stage can not be filled正样本不足以支撑本轮-numPos调到90%以下
卡在stage 3~6不动也不退出负样本太单调增加真实棋盘负样本
训练完成但检测全是误报正样本里混入非棋子回头看vec,清掉脏样本

还有一个常被忽略的问题是内存参数。-precalcValBufSize-precalcIdxBufSize单位是MB,默认只有256MB,训练到后期经常变慢。机器内存足够就给到2048,级联训练会明显提速。若在OpenCV 4.x下找不到opencv_traincascade命令,多半是装的Python包不包含命令行工具,去装完整版OpenCV即可。

4. Python源码:级联分类器加载、棋子ROI精修与字符识别

4.1 加载级联并生成棋子候选矩形

训练完成后,目录里会多出一个.xml文件,推理阶段直接用cv2.CascadeClassifier加载。先看最基本的检测代码:

import cv2 cascade = cv2.CascadeClassifier("model/chess_cascade.xml") def detect_piece_candidates(frame): gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) rects = cascade.detectMultiScale( gray, scaleFactor=1.05, # 尺度步长,1.05比1.1准但慢 minNeighbors=4, # 抑制重复框,越大误检越少但漏检越多 minSize=(24, 24), maxSize=(180, 180) ) return rects

scaleFactor是图像金字塔的缩放步长,1.05表示每次把图像缩小5%再扫一遍,值越小扫的层数越多。中国象棋棋子在棋盘上的尺寸相对稳定,scaleFactor=1.05通常能在准确率和速度之间取得平衡。minNeighbors控制一个候选区域要被多少个邻居矩形确认才算数,如果发现一枚棋子周围叠了三四个框,就往大到5;如果漏检严重,就先降回3。equalizeHist做的是全局直方图均衡,能缓解棋盘受单侧光影响导致半边过亮半边过暗的问题。

4.2 候选矩形不等于棋子:圆度校验过滤背景误检

级联输出的矩形框只是候选,尤其棋盘外的手、笔、纸也可能被框进去。常见做法是拿一个圆度校验做前置过滤,因为棋子是个标准圆形,而误检的矩形通常轮廓不规则:

import numpy as np def is_circular(roi, min_circularity=0.25): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, th = cv2.threshold(gray, 180, 255, cv2.THRESH_BINARY) th = cv2.morphologyEx(th, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return False c = max(contours, key=cv2.contourArea) area = cv2.contourArea(c) perimeter = cv2.arcLength(c, True) if perimeter == 0: return False circularity = 4 * np.pi * area / (perimeter * perimeter) return circularity > min_circularity

圆度的取值范围最大是1,越接近1越圆。棋子的二值轮廓圆度一般在0.5以上,低于0.25的基本都是不规则物体。这段代码把ROI先做阈值分割,再提取最外层轮廓算圆度,好处是不需要额外训练,坏处是对光照太暗的图会失灵,所以阈值180这个值要根据具体环境微调。

4.3 字符识别:预旋转模板匹配的实现

圆形候选确定后,进入最关键的字符识别。最简单的可行方案是模板匹配,但注意棋子可能被转到任何角度,所以需要预先把模板旋转到多个角度,再和ROI做归一化匹配。我一般把模板做成一个类,初始化时离线旋转好,避免在线推理时反复计算旋转矩阵:

import os import cv2 class ChessTemplates: def __init__(self, template_dir, step=10): self.step = step self.items = {} for name in os.listdir(template_dir): path = os.path.join(template_dir, name) if not os.path.isfile(path): continue tpl = cv2.imread(path, cv2.IMREAD_GRAYSCALE) tpl = cv2.resize(tpl, (64, 64)) rots = [] for angle in range(0, 360, step): M = cv2.getRotationMatrix2D((32, 32), angle, 1.0) rot = cv2.warpAffine(tpl, M, (64, 64), flags=cv2.INTER_CUBIC) rots.append(rot) self.items[os.path.splitext(name)[0]] = rots def match(self, roi_bin): best_name, best_score = None, -1.0 for name, rots in self.items.items(): for rot in rots: res = cv2.matchTemplate(roi_bin, rot, cv2.TM_CCOEFF_NORMED) _, score, _, _ = cv2.minMaxLoc(res) if score > best_score: best_name, best_score = name, score return best_name, best_score

配套的调用逻辑分三步:ROI转灰度并均衡化、OTSU二值化反色、再丢进match。直接使用cv2.matchTemplateTM_CCOEFF_NORMED,它返回的是归一化相关系数,对整体亮度不敏感,非常适合棋子这种“浅底深字”的二元结构。旋转模板时用INTER_CUBIC插值,是因为字符笔画经过旋转后容易出现锯齿,插值质量直接决定识别上限。

def recognize_piece(roi, template_matcher): gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) gray = cv2.resize(gray, (64, 64)) _, roi_bin = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) return template_matcher.match(roi_bin)

注意二值化用了THRESH_BINARY_INV反转,因为模板匹配对“白底黑字”和“黑底白字”是非常敏感的,必须统一。match返回的score是最大相关系数,一般在0.6~0.9之间,低于0.5的匹配结果基本不可信。把该阈值留在调用处判断,别硬编码在类里。

4.4 组合成完整的单张图片推理函数

把上面几块串成入口函数,输出一个带棋子名、坐标、置信度的结构化结果:

def analyze_board(frame, cascade, matcher, min_score=0.55): rects = detect_piece_candidates(frame) result = [] for x, y, w, h in rects: roi = frame[y:y + h, x:x + w] if not is_circular(roi): continue name, score = recognize_piece(roi, matcher) if score >= min_score: result.append({ "name": name, "bbox": [int(x), int(y), int(w), int(h)], "score": round(float(score), 4) }) return result

这个函数的执行顺序是固定的:先检测,再圆度过滤,最后字符识别。顺序不能反,因为每个候选矩形都要做一次模板匹配,如果先识别再做圆度过滤,等于让所有误检都白白消耗了模板匹配的计算量。min_score设为0.55在本项目里能挡住大多数不清晰匹配,实际值可以画出来看几帧再调。

5. 实战排错:中国象棋棋子重复检测、漏检的消解与实时性能手感

5.1 半枚棋子出两个框:用NMS合并重叠矩形

棋盘棋子密集,cascade经常出现同一枚棋子被两个矩形同时框住的情况。直接调minNeighbors能压一部分,但副作用是漏掉被遮挡的棋子。更可控的做法是把级联的minNeighbors先适当调低,让它把候选都放出来,再用非极大值抑制(NMS)合并重叠框:

import numpy as np def nms(rects, scores, threshold=0.3): if len(rects) == 0: return np.array([]) rects = np.array(rects, dtype=np.float32) scores = np.array(scores, dtype=np.float32) x1 = rects[:, 0] y1 = rects[:, 1] x2 = rects[:, 0] + rects[:, 2] y2 = rects[:, 1] + rects[:, 3] order = scores.argsort()[::-1] keep = [] while order.size > 0: i = order[0] keep.append(i) xx1 = np.maximum(x1[i], x1[order[1:]]) yy1 = np.maximum(y1[i], y1[order[1:]]) xx2 = np.minimum(x2[i], x2[order[1:]]) yy2 = np.minimum(y2[i], y2[order[1:]]) inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) area_i = (x2[i] - x1[i]) * (y2[i] - y1[i]) area_o = (x2[order[1:]] - x1[order[1:]]) * (y2[order[1:]] - y1[order[1:]]) iou = inter / (area_i + area_o - inter) order = order[1:][iou <= threshold] return rects[keep].astype(int)

IoU阈值0.3意味着两个矩形重叠超过30%就只保留置信度高的那个。在棋盘场景里,同一枚棋子的两个检测框往往重叠70%以上,而相邻两枚棋子之间的IoU通常不到10%,所以0.3是个安全值。调用时把cascade返回的候选框和每个框对应的rejectLevels或匹配分数传给nms,就能在不动minNeighbors的情况下把重复框压掉。

5.2 误检和漏检的调参顺序

联调阶段最烦人的是“这里多一个框、那里少一个框”。我习惯按下面这张表的顺序逐项检查,而不是一上来就改训练参数:

现象优先动作备用手段
一枚棋子重复框后续NMS /minNeighbors4→6缩小maxSize
把棋子外物体框进来检查负样本里是否缺少同类背景提高min_score到0.6
棋盘内侧棋子漏检scaleFactor降到1.04先放大输入图再检测
远景小棋子漏检调低minSize到20级联判定阈值不够,回训级联

误检多时,首先要排除的是“负样本里根本没有这类背景”的情况。比如摄像头会拍到鼠标键盘,而训练负样本全是棋盘纹理,那鼠标必然会被框出来。临时提高min_score只能掩盖现象,治本还是补负样本回炉。漏检则相反,多半是输入分辨率太低或scaleFactor太大,让特征在金字塔中间层被跳过去了。

5.3 边跑边调试:可视化与性能优先的取舍

实时场景里如果每帧都要跑模板匹配,CPU会迅速打满。级联检测本身很快,瓶颈几乎全在字符识别层的旋转模板匹配上。先做一个最简单有效的优化:把输入帧缩放后再送检测,坐标映射回原图即可。

scale = 0.6 small = cv2.resize(frame, None, fx=scale, fy=scale) rects = cascade.detectMultiScale(cv2.equalizeHist(cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)), scaleFactor=1.05, minNeighbors=4) rects = np.array(rects) / scale for x, y, w, h in rects.astype(int): roi = frame[y:y + h, x:x + w]

缩放后检测更快,但ROI变小,字符识别的成功率会下降。压缩到0.6倍后,每个棋子的ROI可能只有30×30像素,模板匹配依然能跑,DPI足够。再往后可以试着把step从10度改成15度,模板数减少三分之一,识别精度只掉一点点。最后如果需要上真实时路,建议把模板匹配换成基于cv2.matchShapes的轮廓匹配,或干脆把ROI归一化后交给轻量CNN,不过那就是另一套系统了。

6. 用颜色先验把识别收窄到七个字符:误报率大幅下降的验证技巧

6.1 红色像素比例判黑红

模板匹配在“车、马、炮”这些跨颜色同名棋子之间极易混淆,因为红车和黑车只有颜色差异,字形几乎一样。一个低成本高收益的技巧,是在字符识别前先判断棋子属于红方还是黑方,把模板候选集从十四个直接砍到七个。判断依据很直接:红棋字符偏红,黑棋字符偏黑。

def split_color(roi): hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) red1 = cv2.inRange(hsv, (0, 50, 40), (10, 255, 255)) red2 = cv2.inRange(hsv, (170, 50, 40), (180, 255, 255)) red_ratio = (cv2.countNonZero(red1) + cv2.countNonZero(red2)) \ / (roi.shape[0] * roi.shape[1]) black = cv2.inRange(hsv, (0, 0, 0), (180, 255, 70)) black_ratio = cv2.countNonZero(black) / (roi.shape[0] * roi.shape[1]) if red_ratio >= 0.02: return "red" if black_ratio >= 0.08: return "black" return None

OpenCV里HSV色彩的H通道范围是0到180,红色恰好在0附近和180附近各占一段,所以要写两个inRange再做或运算。red_ratio取0.02是因为红棋字符笔画占ROI总面积的比例通常就在2%~5%之间,太低会把黑棋的暗红色木纹也算进去。拿到红黑标签后,只加载对应颜色的七类模板,其余模板直接跳过。这一步能让匹配耗时减半,因为在线匹配的主要成本就是模板数量乘以旋转角度数。

6.2 用混淆矩阵量化识别质量

调完颜色分流后,别只靠肉眼看几张图就说“效果好了”。把测试集里每张棋子的真实名称和预测名称记下来,统计成一个混淆矩阵,一版模型的识别短板马上现形:

from collections import defaultdict confusion = defaultdict(int) for true_name, pred_name in test_samples: confusion[(true_name, pred_name)] += 1 print("Top confusions:") for (true_name, pred_name), cnt in sorted(confusion.items(), key=lambda x: -x[1]): if true_name != pred_name: print(f"{true_name} -> {pred_name}: {cnt}")

正常项目里最常见的混淆项是“兵”和“卒”互认、“士”和“仕”互认,因为这两对字符字形高度相似。颜色分流解决的是跨颜色误报,解决不了字形相似的内部混淆。想再进一步,可以对这两组易混字符单独提高min_score阈值,或者把这几个字符单独再训练一次小级联。数据集换成不同光线下的四十盘对局照片,主对角线的变化会让你立刻知道该补训练数据还是调模板阈值。

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

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

大模型调参实战:Temperature与Top-P原理与应用

1. 大模型调参的双刃剑&#xff1a;Temperature与Top-P的本质解析 作为在AI领域摸爬滚打多年的老手&#xff0c;我见过太多开发者对着大模型的输出结果挠头——为什么同样的提示词&#xff0c;有时能产生逻辑严谨的代码&#xff0c;有时却冒出天马行空的诗句&#xff1f;这背后…

作者头像 李华
网站建设 2026/9/12 18:04:53

强化学习数学原理:MDP与贝尔曼方程解析

1. 项目概述《强化学习的数学原理》是赵世钰教授关于强化学习理论基础的经典著作&#xff0c;第九章作为全书的重要章节&#xff0c;深入探讨了强化学习中的核心数学概念和算法原理。作为一位长期从事机器学习研究的工程师&#xff0c;我发现这一章的内容对于理解强化学习的底层…

作者头像 李华
网站建设 2026/9/12 18:01:40

Spring Boot与MQTT构建高效物联网监控系统

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

作者头像 李华