news 2026/10/5 6:20:29

CUB-200-2011鸟类数据集详解:PyTorch训练避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CUB-200-2011鸟类数据集详解:PyTorch训练避坑指南

简介:面向需要用CNN对CUB-200-2011鸟类数据集做200类细粒度图像分类的学习者与研究人员,这份压缩包提供了一个轻量可跑的完整训练方案。包内围绕数据读取、模型搭建、训练评估三个环节组织代码:三个Python脚本分别覆盖CUB数据集加载与预处理、卷积网络结构定义、训练及分类流程;两个pyc文件是Python3.6运行产生的编译缓存,用于加速模块加载。整套资源仅5个文件、约15KB,结构紧凑,适合在普通实验环境中快速验证思路,也可作为入门级鸟类识别项目的骨架。目前已有648人学习下载,对刚接触细粒度图像分类、想从公开数据集入手复现CNN基线的读者较有参考价值。拿到后可对照脚本理解训练CUB_200数据集的关键步骤,并根据自身显卡与数据路径调整超参数,快速跑通200类鸟类分类实验。

1. CUB-200-2011 鸟类数据集:细粒度识别训练为什么都拿它当试金石

CUB-200-2011(Caltech-UCSD Birds-200-2011)是目前用得最多的细粒度图像识别数据集之一,200 个鸟类类别、11788 张图像,每张图都带类别标注、边界框、属性标注和部位关键点。标题里那个doublesi7是资源上传者的标识,网上流传的 CUB 压缩包版本很多,第一步不是急着解压训练,而是先确认你手里拿到的是官方原始数据,不是别人改过目录结构或重新划分过的版本。很多新手在这儿就翻车了——拿到的包缺文件、标签文件和图片对不上号,后面训练再卖力也是白搭。

本文从解压原始压缩包开始,讲清楚 CUB-200-2011 的文件目录结构、训练集/测试集的官方划分逻辑,再给出把数据集转成 PyTorch 可直接读取格式的具体脚本和训练参数。适合第一次拿 CUB 练手的同学,也适合准备做细粒度识别项目、想快速评估一个模型在细粒度任务上表现的工程师。CUB 数据量不算大,单卡就能跑通,但里面的坑一点都不少,尤其是官方文件格式和常见库的默认行为之间的错位,下面逐个拆开说。

2. 解压后的真实目录结构:五个关键文件决定训练成败

2.1 官方包里的核心文件与格式约定

官方 CUB-200-2011 压缩包解压后是一个CUB_200_2011目录,里面没有现成的train/、val/子目录,取而代之的是几个文本文件和图片目录。初学者最容易懵的就是这里:模型训练不是从文件夹结构直接读类别,而是要先用这些文本文件把图片和标签对应起来。核心文件如下:

文件内容行格式
images.txt全部图片的清单与相对路径<图片ID> <相对路径>
image_class_labels.txt每张图片的类别标签<图片ID> <类别ID 1-200>
train_test_split.txt每张图片的划分标记<图片ID> <0/1>,1 为训练集
bounding_boxes.txt每张图片的边界框<图片ID> <x> <y> <w> <h>(归一化坐标)
classes.txt200 个类别的名称对照<类别ID> <拉丁学名>
attributes/312 个二值属性标注每类一个文件,按属性 ID 记录

这套设计很老派但很严谨——图片 ID 是全局线索,所有标注文件都以它为主键对齐。图片存放在images/下,按类别建子目录,子目录名类似001.Black_footed_Albatross、002.Laysan_Albatross。注意类名带空格、带下划线甚至带句点,后面写脚本处理路径时要特别小心,别在字符串拼接上踩坑。

train_test_split.txt里的划分是按图片 ID 随机分的,不是按类别均衡抽样。官方大约给了 5994 张训练图、5794 张测试图,每类大约 30 张训练图,不多不少。看清楚这一点很关键:如果你想做自己风格的验证,可以不管官方划分重新随机分,但如果是为了和论文数值对比,就必须用官方 split,否则评估结果没有可比性。

2.2 属性与关键点标注:不是装饰品

attributes/目录里存的是 312 个二值属性标注,比如翅膀颜色、喙形状、腹部纹理这些,每个属性 0 或 1。parts/目录存的是 15 个部位关键点的坐标,像头顶、眼睛、喙、左翼、右翼这些。这两类标注很多新手看一眼就关掉了,实际上它们对训练帮助很大:

  • 属性标注可以直接当辅助监督信号,在分类头之外再加一个多标签输出头,让模型同时学习"这是什么鸟"和"这只鸟有什么特征",细粒度数据量少,多任务能缓解过拟合。
  • 部位关键点可以配合边界框做局部特征提取,常见做法是把关键点区域裁剪出来单独过一个小网络,再把特征拼回主分支。
  • 边界框本身也是免费的礼物——用bounding_boxes.txt先把鸟裁剪出来再训练,通常能稳定涨 2 到 4 个点,因为背景噪声被去掉了。

我一般建议拿到数据集后先写一段脚本检查各文件行数是否一致。CUB 原始包相对规整,但网上转手过的压缩包经常发生images.txt和图片目录对不齐的情况,往往解压时丢了文件或者文件名被批处理改坏了。行数不一致强行走训练,轻则 IndexError,重则标签错位一切白干。

2.3 官方划分的唯一正确读法

有人问过我用train_test_split.txt读训练集还是自己按类别文件夹里文件数量去分。答案是永远以train_test_split.txt为准。这个文件第二列是 1 表示训练,0 表示测试,注意别读反。写脚本时逐行读取、按 ID 生成路径,用字典或集合做快速查找。常见的风险点在于:文件路径和实际images/下的路径大小写是否一致、换行符是 LF 还是 CRLF。Windows 上用记事本编辑过这类文件后换行符会变,Linux 下读进来每行末尾多一个\r,拼接路径时死活找不到文件。这类问题排查半天,其实就是一个不可见字符的事,后续章节有具体解法。

3. 把 CUB 转成训练格式:转换脚本与 Dataset 加载的完整方案

3.1 从官方格式到 ImageFolder 目录树的转换脚本

不管你是用 PyTorch 还是 TensorFlow,大多数现成训练脚本都默认从"一个类别一个文件夹"的目录结构里读数据。CUB 官方格式并不是这样,所以我第一步永远是写个脚本把数据整理成标准的train/类别名/图片和test/类别名/图片结构。下面这个脚本就是干这个的,直接存成prepare_cub.py运行即可。

import os import shutil from collections import defaultdict source_root = "CUB_200_2011" target_root = "cub200_prepared" def read_lines(filepath): # 统一处理换行符和编码问题,避免 Windows 和 Linux 混用翻车 with open(filepath, "r", encoding="utf-8", errors="ignore") as f: lines = f.read().splitlines() return lines id_to_path = {} for line in read_lines(os.path.join(source_root, "images.txt")): parts = line.split() if len(parts) != 2: continue # 跳过空行或格式异常的行 img_id, relative_path = parts id_to_path[img_id] = relative_path id_to_label = {} for line in read_lines(os.path.join(source_root, "image_class_labels.txt")): img_id, label = line.split() id_to_label[img_id] = int(label) id_to_split = {} for line in read_lines(os.path.join(source_root, "train_test_split.txt")): img_id, split_flag = line.split() id_to_split[img_id] = int(split_flag) for img_id, relative_path in id_to_path.items(): label = id_to_label[img_id] # 类别 ID 转成两位/三位数字前缀,和文件夹名字保持一致 split = "train" if id_to_split[img_id] == 1 else "test" class_name = os.path.basename(os.path.dirname(relative_path)) src = os.path.join(source_root, "images", relative_path) dst_dir = os.path.join(target_root, split, f"{label:03d}_{class_name}") os.makedirs(dst_dir, exist_ok=True) shutil.copy2(src, os.path.join(dst_dir, os.path.basename(relative_path))) print("转换完成")

这段脚本的逻辑很简单:从三个官方文件分别读出图片路径、类别 ID 和划分标记,再按类别文件夹整理复制。有几处细节值得说明:

  • splitlines()能同时处理\n和\r\n,比手动strip()更稳。
  • errors="ignore"是给某些转手压缩包里图片文件名混入异常字符的情况兜底的,宁可忽略一个生僻字符也不要让整个脚本崩掉。
  • class_name取的是相对路径的父目录名,因为官方图片本来就在按类分好的子目录里,直接复用比去classes.txt再查一次省事。
  • 目标目录加{label:03d}_前缀是为了排序时类别数字不乱序,纯按类名排的话 10 会排在 2 前面。

跑完脚本后检查一下cub200_prepared/test下是不是 200 个目录、train下同样 200 个目录,总数别错。这一步 5 分钟就能验证完,别省。

3.2 不复制文件直接写的 PyTorch Dataset 类

上一节的做法把数据复制了一份,磁盘占用多一倍。CUB 原图不大,总共也就 1GB 上下,复制一份没压力。但如果你的磁盘紧张,或者希望随时随地能回到官方原始格式,我更推荐第二种方案:不整理目录,直接继承torch.utils.data.Dataset写一个 CUB 专用加载器。这样文件始终只有一份,读标签全靠索引文件,之后想加关键点监督、属性标注都很好扩展。核心代码:

import os from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class CUBCustomDataset(Dataset): def __init__(self, source_root, split="train", transform=None): self.source_root = source_root self.transform = transform self.samples = [] id_to_path = {} id_to_label = {} id_to_split = {} id_to_bbox = {} with open(os.path.join(source_root, "images.txt")) as f: for line in f.read().splitlines(): parts = line.split() if len(parts) == 2: id_to_path[parts[0]] = parts[1] with open(os.path.join(source_root, "image_class_labels.txt")) as f: for line in f.read().splitlines(): parts = line.split() if len(parts) == 2: id_to_label[parts[0]] = int(parts[1]) - 1 # 标签从0开始 with open(os.path.join(source_root, "train_test_split.txt")) as f: for line in f.read().splitlines(): parts = line.split() if len(parts) == 2: id_to_split[parts[0]] = int(parts[1]) with open(os.path.join(source_root, "bounding_boxes.txt")) as f: for line in f.read().splitlines(): parts = line.split() if len(parts) == 5: id_to_bbox[parts[0]] = [float(x) for x in parts[1:]] for img_id, relative_path in id_to_path.items(): if split == "train" and id_to_split[img_id] == 1: self.samples.append((relative_path, id_to_label[img_id], id_to_bbox[img_id])) elif split == "test" and id_to_split[img_id] == 0: self.samples.append((relative_path, id_to_label[img_id], id_to_bbox[img_id])) def __len__(self): return len(self.samples) def __getitem__(self, idx): relative_path, label, bbox = self.samples[idx] img_path = os.path.join(self.source_root, "images", relative_path) image = Image.open(img_path).convert("RGB") if self.transform: image = self.transform(image) sample = {"image": image, "label": label, "bbox": bbox, "path": relative_path} return sample

这个类返回的样本带bbox和path,训练阶段用不上path,但 Debug 的时候想看某张预测错的图到底长什么样,path帮大忙。还有两个容易做错的地方:

  • 标签必须减 1。官方image_class_labels.txt里类别是从 1 到 200,PyTorch 的CrossEntropyLoss要求标签范围是[0, C-1],不减 1 会直接报错或者在训练后期出现梯度异常但 loss 却不明显变动的诡异现象。
  • Image.open()打开的是懒加载对象,实际读像素发生在后续 transforms 时。如果文件缺失或损坏,错误抛出点往往在transform里而不是open时,排查的时候别只盯__getitem__的第一行。

3.3 最小可训练脚本与关键超参数

Dataset 写好了,训练脚本可以直接套用常见的分类训练模板。下面给出最小可跑的部分,重点标注细粒度场景下的几个关键超参数:

import torch from torchvision import models, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = torch.nn.Linear(model.fc.in_features, 200) model = model.cuda() train_loader = torch.utils.data.DataLoader( CUBCustomDataset("CUB_200_2011", split="train", transform=train_transform), batch_size=32, shuffle=True, num_workers=4, drop_last=True) criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=90)

这里的选择都是有原因的:

  • 输入尺寸设 224 而不是 128。细粒度识别的关键差异往往集中在喙、眼睛、翅膀边缘这些局部区域,分辨率不够很难分辨近缘种。CUB 原图分辨率不高,224 是个性价比合适的尺寸。
  • 预训练权重用 ImageNet 预训练,不要随机初始化。CUB 每类只有 30 张训练图,随机初始化在这个数据量上几乎必然过拟合到惨不忍睹。ImageNet 预训练模型对纹理颜色已有较强先验,迁移过来后收敛速度快得多。
  • batch size 32 对单卡足够,如果显存小降到 16,但学习率最好同步减半或用 warmup 弥补,不然前几个 epoch 的 loss 会飘得厉害。
  • SGD 配 0.01 初始学习率在这个量级比 Adam 稳。Adam 收敛快但最终精度经常差零点几个点,细粒度任务更要精扣这些细节。如果不想手动调,常见做法是把 T_max 设为预计的总 epoch 数,配合余弦退火慢慢把学习率降下来。

整体训练 90 个 epoch 大概在 RTX 3090 上跑 3 到 4 小时,数据加载会成为瓶颈,num_workers至少给 4。如果机器的 CPU 核数不多,建议先把新版本的 cup 包安好。

4. 训练 CUB 数据集的避坑指南:五条高频翻车记录

4.1 图片宽高比悬殊导致细节丢失

现象:训练 loss 正常下降,但验证准确率一直卡在 40% 上下,怎么调学习率都没用。

原因:CUB 里很多鸟的原始照片是细长构图的,比如蜂鸟、海鸥这类,直接Resize((224, 224))等于把长宽比强行压成 1:1,鸟翼和喙的高频细节被压缩变形。模型学到的是扭曲后的形状,在测试集上自然泛化不动。

解决:换用transforms.Resize(256)保持比例缩放,再接transforms.CenterCrop(224),训练时用RandomResizedCrop(224)代替固定 Resize。RandomResizedCrop自带随机裁剪和比例扰动,对这种构图多样的数据更友好。验证阶段坚持 CenterCrop,保证评估结果可复现。

train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])

scale=(0.6, 1.0)是给细粒度场景专门调过的,下限 0.6 保证裁剪后的区域仍然足够大,近缘类才分得清。

4.2 类别数固定写死 200 还是 201

现象:模型加载时报Expected input batch_size (32) to match target size (31)一类的维度不匹配错误,或者验证时不报错但准确率全是 0。

原因:很多人直接拿 ImageNet 的分类脚本改,ModelNet 默认输出 1000 类;也有人把classes.txt读出来后发现实际有 201 行,里面包含一个背景类。CUB 官方classes.txt恰好是 200 行,但网上有些版本额外加了一行0.background。两边如果没对齐,输出层维度和标签范围必有一个错。

解决:以image_class_labels.txt里的类别 ID 最大值为准。用一个简单断言确认:

import numpy as np labels = [] with open("CUB_200_2011/image_class_labels.txt") as f: for line in f: labels.append(int(line.split()[1])) assert max(labels) == 200 and min(labels) == 1, "标签范围异常" print("类别数验证通过")

这段代码建议在任何训练前先跑一次,成本几乎是零,能挡掉大部分转手数据集的坑。

4.3 评估时开启 Dropout 或 BatchNorm 误用

现象:训练集准确率 90% 以上,验证集准确率却比训练低 20 个点,怎么调都压不下去。

原因:细粒度任务普遍用 ResNet 这类带 BatchNorm 的模型,如果评估时忘了model.eval(),BN 层的统计量会用当前 batch 的均值和方差,而不是训练时滑动平均的全局统计量。预测时对单张图喂数据,BN 的 mini-batch 统计几乎完全失效,结果惨不忍睹。

解决:验证脚本里必须在推理前手动切换:

model.eval() with torch.no_grad(): for batch in val_loader: outputs = model(batch["image"].cuda()) _, preds = torch.max(outputs, 1) # 统计准确率

这一行model.eval()就是所谓的"玄学",谁忘谁翻车。训练阶段用model.train(),验证阶段用model.eval(),别省这一行。

4.4 数据加载器中边界框裁剪与按图像归一化的顺序问题

现象:用了边界框裁剪后,训练和验证结果像坐过山车,先升后降,最终还不如不用裁剪。

原因:边界框是从bounding_boxes.txt读出来的坐标,但官方标注是归一化的,范围在 0 到 1 之间。没有先乘以图片宽高就直接拿去img.crop(),或者乘以的是变换前的宽高但变换后已经变成了 224,两处坐标对不上,等于每次 clip 都在随机位置切,数据分布被搞乱了。

解决:把边界框转成像素坐标的时点必须放在所有Resize之前,严格用原始图片尺寸乘。可以写在__getitem__的最前面,之后再做数据增强:

def __getitem__(self, idx): relative_path, label, bbox = self.samples[idx] img_path = os.path.join(self.source_root, "images", relative_path) image = Image.open(img_path).convert("RGB") width, height = image.size x, y, w, h = bbox # 这里乘的是原始宽高,不是 224 left, top = int(x * width), int(y * height) right, bottom = int((x + w) * width), int((y + h) * height) image = image.crop((left, top, right, bottom)) if self.transform: image = self.transform(image) ......

一条血泪经验:任何检测任务中读取标注坐标时,永远搞清楚"这个坐标是归一化的还是像素的"再动手,顺序错一个地方,后面全是黑匣子。

4.5 文件路径大小写与隐藏字符导致图片加载失败

现象:assert image is not None之类的检查随机触发,但重新跑一遍可能又好了,位置还不固定。

原因:CUB 原始包在 macOS 上解压时,部分文件名的大小写可能被 APFS 自动规范化,images/001.Black_footed_Albatross/...变成images/001.black_footed_albatross/...,而images.txt里是原始大小写。某些文件系统不区分大小写(Windows 默认不区分、Linux 严格区分),于是同一段代码在 Linux 上崩、在 Windows 上不崩。

解决:先把images.txt里的路径都lower(),再和磁盘上实际目录的中实际文件名对比一遍,找出差异批量重命名。最省心的做法是第一节的prepare_cub.py复制前先检查if not os.path.exists(src)时尝试小写路径兜底。这属于典型的环境差异坑,遇到别怀疑代码逻辑,先怀疑文件系统。

5. 用 CUB 训练结果的验证技巧:除了 top-1 还要看这三样

5.1 混淆矩阵定位近缘类错误

CUB 的 200 个类别里,很多是同一个属下的不同种,外观差异微小,比如各种莺和鹟。单纯看 top-1 准确率,你只知道整体水平,不知道模型具体在哪些类上挣扎。我习惯在每个 checkpoint 验证时顺手导出混淆矩阵,接在 eval 代码后面:

from sklearn.metrics import confusion_matrix import numpy as np all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for batch in val_loader: outputs = model(batch["image"].cuda()) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch["label"].numpy()) cm = confusion_matrix(all_labels, all_preds) # 找出最易混淆的类别对 np.fill_diagonal(cm, 0) row_sum = cm.sum(axis=1) confused = np.unravel_index(np.argmax(cm), cm.shape) print(f"最易混淆:{confused[0]+1} 类被预测成 {confused[1]+1} 类,次数 {cm[confused]}")

看到某个具体类别总是被预测成另一个具体类别,你就能回头翻classes.txt看这两类是不是真的长得很像。如果模型犯的错误符合人类直觉,说明它学到了合理的细粒度特征,只是数据本身难;如果错误完全无规律,那多半是训练流程哪里出了问题,比如标签错位或增强过度。

5.2 注意力图可视化检验模型是否真的在看鸟

top-1 指标是数字,模型到底看的是鸟本身还是背景里那根树枝,数字看不出来。加载训练好的模型,用 Grad-CAM 对几张验证图生成热力图,重点看模型最后一层卷积对"喙部""眼睛""翼尖"这些部位的响应强度。如果热力图大面积集中在背景区域,说明模型在靠场景作弊,比如某种鸟总是出现在海边,模型学会了看到海就猜是海鸟,这个模型部署到新环境必然崩。

这个检查对细粒度模型尤其重要,因为细粒度任务要求的就是让模型关注局部判别特征,而不是整体风格。以我的习惯,每训练完一个模型都会在 20 张随机验证图上做一次可视化,发现不对就回去调增强策略或加边界框裁剪——这比盲目调学习率有效得多。

5.3 多标签辅助头的扩展方向与个人收尾

关于这个数据集有一个常被忽略的玩法:既然 CUB 自带 312 个属性标注,为什么不顺手把分类头换成"分类 + 属性预测"的多头结构?做法是在 ResNet 的最后一个全连接层之后并出两个头,一个输出 200 类的分布,一个输出 312 个二值属性,两个损失直接相加。你会发现属性分支的梯度对主干参数有很强的正则化作用,细粒度主分类的准确率经常跟着涨 1 到 2 个点,付出的代价只是多一个输出层和一次矩阵乘法。这是这个数据集特有的红利,在 ImageNet 上用不到的技巧。

回看这几年的训练经历,我在 CUB 上最大的教训是:永远先验证数据流程,再调模型结构。数据路径对了,模型训练只是时间问题;数据路径错了,后面每一步都在给错误叠加正确性,找 bug 的难度会几何级增长。每次拿到新数据集,先写 20 行脚本检查文件行数、标签范围、图片能否正常读取,跑通之后再谈训练和调参。这个习惯帮我省下过很多个本可避免的通宵。希望帮到你。

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

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

校园局域网课程设计:从拓扑到抓包的闭环实践指南

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

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

OpenCV安装教程:Python与numpy版本匹配避坑指南

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

作者头像 李华
网站建设 2026/10/5 6:19:55

WinXP虚拟机搭建Tornado 2.2实战指南:从安装到调试的完整流程

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

作者头像 李华
网站建设 2026/10/5 6:19:32

Ubuntu Server 18.04安装全攻略:从启动盘制作到系统初始化

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

作者头像 李华
网站建设 2026/10/5 6:19:30

Java Web校园驿站管理系统毕业设计全攻略:从Servlet到答辩

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

作者头像 李华
网站建设 2026/10/5 6:18:10

人工智能指挥辅助决策系统架构:四类Agent协作与AHP方案优选详解

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

作者头像 李华