news 2026/9/30 8:01:11

PyTorch实现高光谱图像分类:2D CNN从数据预处理到实战全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实现高光谱图像分类:2D CNN从数据预处理到实战全流程

第一次做高光谱图像分类实战,很多人的第一反应是直接上3D CNN或者各种注意力机制模型,结果数据预处理还没搞明白,就被复杂的网络结构折腾到怀疑人生。我的建议很直接:入门阶段就用PyTorch写一个2D CNN,先把高光谱图像分类的完整链路走通,再去碰那些花哨模型。这套方案代码量小、思路清楚,Indian Pines这种经典数据集在普通显卡上几分钟就能训练一轮,非常适合作为遥感深度学习的第一课。

这篇文章就围绕基于PyTorch的高光谱图像分类2D_CNN网络实战来写。我会把实际项目中总结下来的数据预处理方法、网络结构设计、训练技巧和踩坑记录都整理出来,代码可以直接复制去跑。你不需要懂太深的遥感背景,只要会一点Python和PyTorch基础,就能跟着把整个流程跑通,并且在测试集上拿到一个还不错的分类精度。

1. 先搞懂高光谱图像分类到底在做什么

1.1 高光谱图像和普通照片差在哪

普通相机拍出来的照片一般只有3个波段,也就是红、绿、蓝。高光谱成像仪不一样,它能在几百个连续的窄波段上同时成像,每个波段对应一条光谱通道。拿Indian Pines数据集来说,原始图像是145乘以145像素,去掉了噪声严重的吸水波段之后,每个像素带有200个波段的光谱信息。

这意味着每个像素不再是一个简单的RGB颜色值,而是一条记录了地物反射率随波长变化的光谱曲线。不同物质的光谱曲线差异很大,比如植被在近红外区间反射率会明显升高,水体则表现为强烈的吸收特征。高光谱分类的核心,就是利用这种丰富的光谱信息来判断每个像素属于哪一类地物。

但波段多不一定是好事。200个波段的数据维度很高,如果直接用原始光谱向量去做分类,容易陷入维度灾难,而且标注样本往往很少,模型很容易过拟合。好在高光谱图像还有一个特点:相邻波段之间相关性很强,信息冗余度高,这给PCA这类降维方法留了很大空间,后面我会详细讲。

在分类任务里,高光谱图像通常需要同时利用光谱信息和空间信息。只使用单个像素的光谱曲线,相当于把每个像素孤立看待,忽略了地物的空间上下文。而图像里的地物往往是成片分布的,比如一块农田里相邻的像素大概率属于同一个类别。2D CNN处理高光谱图像时,正是通过截取像素周围的邻域小块来同时获取光谱和空间特征。

1.2 2D CNN凭什么能处理高光谱数据

很多初学者会有一个疑问:高光谱图像有几百个波段,2D CNN的卷积核是二维的,怎么处理这种数据?其实很简单,把波段数当作通道数就行了。

在PyTorch里,Conv2d的输入要求是(batch_size, channels, height, width)这个形状。我们截取一个像素周围11乘以11的邻域,每个邻域有200个波段,那这个patch的形状就是(200, 11, 11)。把200当通道数、11当高和宽,直接就能喂给2D卷积层。也就是说,2D卷积是在空间维度上滑动,同时综合考虑所有波段的特征。

这里有一个特别容易搞混的地方。原始高光谱数据以numpy数组加载进来时,形状往往是(rows, cols, bands),也就是通道在最后一维。直接把这个数组截成patch后得到的是(patch_size, patch_size, bands),必须转置成(bands, patch_size, patch_size)才能输入模型。我在最开始做这个项目的时候,就是忘了这一步,结果模型输入维度报错,排查了半天。

那为什么不直接用3D CNN呢?3D CNN确实能同时建模空间维度和光谱维度之间的关系,分类精度理论上更高,但代价是参数量成倍增加、训练时间长、对小样本数据集更容易过拟合。2D CNN的参数少、收敛快、代码实现简单,作为基线模型非常合适。先跑通2D CNN,后面再升级到HybridSN这类混合模型,也能有一个清晰的对比基准。

2. PyTorch环境与数据预处理

2.1 搭建环境:先把PyTorch跑起来

做这个项目之前,首先得有一个能用的PyTorch环境。我推荐用Anaconda创建独立环境,免得把系统Python搞乱。装的时候不需要纠结太多,直接按官方命令来就可以。

conda create -n hs python=3.9 conda activate hs pip install torch torchvision

如果你是NVIDIA显卡并且想用GPU加速,建议前往PyTorch官网的Get Started页面,根据你的CUDA版本选择对应的安装命令,通常会包含一个cu121或者cu118之类的标识。安装完之后,在Python里验证一下环境是否正常:

import torch print(torch.__version__) print(torch.cuda.is_available())

如果torch.cuda.is_available()返回True,说明GPU版本安装成功。返回False也不代表项目没法做,Indian Pines全图才145乘以145,200个波段,数据集非常小,CPU训练也能在可接受的时间内跑完。我第一次做这个实验的时候就是在笔记本CPU上跑的,训练10个epoch也就几分钟。

还有一个常见问题是版本不匹配。有些朋友安装PyTorch的时候不看Python版本,结果import直接报错。建议Python用3.8到3.11之间的版本,对应PyTorch 1.13到2.x都行,尽量不要追最新,稳定才是关键。Linux下如果用WSL,提醒一下要保证显卡驱动是Windows侧安装的,WSL里面不需要重复装驱动,但需要安装与驱动匹配的CUDA toolkit相关内容。这里不展开太多,遇到问题去查对应系统的安装日志就好。

2.2 数据预处理:从.mat到训练样本

高光谱分类领域最常用的数据集之一就是Indian Pines。网上可以下载到两个关键文件:一个是修正后的高光谱数据,一般是Indian_pines_corrected.mat;另一个是地面真值标签,一般是Indian_pines_gt.mat。两个都是MATLAB的.mat格式,在Python里用scipy读取就行。

import scipy.io as sio data = sio.loadmat('Indian_pines_corrected.mat')['indian_pines_corrected'] gt = sio.loadmat('Indian_pines_gt.mat')['indian_pines_gt'] print(data.shape) # (145, 145, 200) print(gt.shape) # (145, 145)

数据加载进来后,第一步是归一化。高光谱数据各个波段的数值范围不太一样,有的在0到1之间,有的可能到几千,直接输入网络会让训练很不稳定。常用做法是逐波段做z-score标准化,也就是对每个波段单独减去均值、除以标准差。这样每个波段的数值都归一到零均值、单位方差的分布,模型收敛会快很多。

def normalize(data): rows, cols, bands = data.shape data = data.reshape(-1, bands) for b in range(bands): mean = data[:, b].mean() std = data[:, b].std() data[:, b] = (data[:, b] - mean) / (std + 1e-8) return data.reshape(rows, cols, bands)

归一化之后,我会用PCA把200个波段压缩到30个主成分。这里为什么压缩30而不是保留全部?一方面,相邻波段高度相关,200个波段里可能有一大部分的方差信息可以用前几十个主成分表示;另一方面,输入通道减少后,模型参数量会显著下降,训练速度更快,过拟合风险也更低。用sklearn实现非常方便。

from sklearn.decomposition import PCA def apply_pca(data, n_components=30): rows, cols, bands = data.shape data_2d = data.reshape(-1, bands) pca = PCA(n_components=n_components) data_pca = pca.fit_transform(data_2d) return data_pca.reshape(rows, cols, n_components), pca

这里要注意,PCA是对每个像素的光谱向量降维,是在像素维度上做的,不是对整张图做卷积式的处理。fit_transform的输入是像素数乘以波段数,输出是像素数乘以30。

接下来是构造patch。所谓patch,就是给图像里每个有标签的像素,从它周围截取一个11乘以11的邻域小块。这样每个训练样本就同时包含中心像素的光谱特征和周围像素的空间特征。边界上的像素不够11乘以11怎么办?用np.pad做reflect填充,也就是以边界为镜面反射填充,这样不会像零填充那样引入大量无意义的黑色边缘。

import numpy as np def create_patches(data, gt, patch_size=11): rows, cols, bands = data.shape pad = patch_size // 2 data_pad = np.pad(data, ((pad, pad), (pad, pad), (0, 0)), mode='reflect') patches = [] labels = [] for i in range(rows): for j in range(cols): if gt[i, j] == 0: continue patch = data_pad[i:i+patch_size, j:j+patch_size, :] # 转换为 PyTorch 的 NCHW 格式 patch = patch.transpose(2, 0, 1) patches.append(patch) labels.append(gt[i, j]) return np.array(patches), np.array(labels) X, y = create_patches(data_pca, gt, patch_size=11) print(X.shape) # (num_samples, 30, 11, 11) print(y.shape) # (num_samples,)

Indian Pines有标签的像素一共一万出头,也就是X的第一个维度大概在10249左右。这个数据量对深度学习来说不大,所以这类项目最大的挑战就是防止过拟合,后面我会专门讲。

2.3 样本划分:别让数据泄漏坑了你

数据准备好了,接下来就是划分训练集、验证集和测试集。这一步看起来简单,其实里面藏着一个很容易犯的错误:空间数据泄漏。

如果我们把所有有标签的像素随机打乱再划分,那么同一个地物区域的邻近像素可能一部分进了训练集、一部分进了测试集。由于邻近像素在空间上高度相似,模型可能“记住”了区域纹理特征,而不是学习真正可分的光谱特征,这会让测试精度虚高,实际部署到新的遥感图像上的效果打折扣。

更严谨的做法是按区域划分,比如把整幅图切成若干块,取其中几块做训练、剩余做测试,或者在地理位置上划分训练和测试区域。但Indian Pines这种单景小数据集,学术论文里大多数做法仍然是按像素随机划分,只是会明确说明训练样本比例。做项目时我建议至少保证训练集和测试集来自不同的空间区域,或者在做实验对比时固定随机种子,保证可比性。

划分数据时也要处理标签的问题。Indian Pines的gt里,0表示背景,1到16表示不同地物类别。我们的训练只针对有标签的像素,因此create_patches里已经把0跳过了。但有些类别样本极少,比如部分类别只有几十个像素,随机划分后测试集可能只有几个样本,评估结果波动很大。实践中有两种解决办法:一是直接剔除样本数少于某个阈值的类别,二是采用常用的9类划分版本。我自己做对比实验时,会更倾向用类别过滤后的版本,让每个类别的样本量相对均衡。

3. 2D CNN模型搭建与训练流程

3.1 网络结构:一个简单又稳的基线

有了输入样本,接下来就是核心的模型定义。我先给大家一个非常经典、出效果也够稳的2D CNN结构,输入是(30, 11, 11)的patch,输出是类别数。整体思路就是:卷积提取空间特征,批归一化稳定训练,池化降低特征分辨率,最后全连接层做分类。

import torch import torch.nn as nn class CNN2D(nn.Module): def __init__(self, in_channels, num_classes, patch_size=11): super().__init__() self.conv1 = nn.Sequential( nn.Conv2d(in_channels, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv2 = nn.Sequential( nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2) ) self.conv3 = nn.Sequential( nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True) ) # 计算全连接层输入维度 final_size = patch_size // 4 # 经过两次 stride=2 的池化 self.flatten_dim = 128 * final_size * final_size self.fc = nn.Sequential( nn.Dropout(p=0.5), nn.Linear(self.flatten_dim, 256), nn.ReLU(inplace=True), nn.Dropout(p=0.5), nn.Linear(256, num_classes) ) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) x = x.view(x.size(0), -1) x = self.fc(x) return x

为什么要加BatchNorm?因为高光谱不同波段的数据分布差异很大,归一化只在数据输入前做了一次,但网络中间层的输出分布仍然可能偏移。BN层会在每个batch内部做归一化,让中间特征分布稳定下来,训练速度会明显加快,对收敛效果很有帮助。

为什么要加Dropout?这个项目最大的问题不是模型表达能力不够,而是样本太少。两层全连接之间加Dropout,相当于在训练时随机丢弃部分神经元,强迫网络学到更鲁棒的特征,这是小样本训练里性价比很高的正则化手段。

卷积核大小选3乘以3,padding为1,这样卷积不会改变特征图尺寸。池化层用stride=2,会把11乘以11的patch先变到5乘以5,再变到2乘以2。所以最终进入全连接层前,空间尺寸是2乘以2,乘以128个通道,得到512维特征。你可以自己算一下,如果patch_size改成了13或者15,这个flatten_dim也要相应调整。

3.2 Dataset与DataLoader:高效读取训练数据

模型定义好之后,需要把numpy数组包装成PyTorch的Dataset,再用DataLoader加载。这一步看起来是模板代码,但有几个细节容易出问题。

from torch.utils.data import Dataset, DataLoader class HyperspectralDataset(Dataset): def __init__(self, X, y): self.X = torch.from_numpy(X).float() self.y = torch.from_numpy(y).long() def __len__(self): return len(self.y) def __getitem__(self, idx): return self.X[idx], self.y[idx] - 1 # 标签从1开始,转成0开始

这里有个细节:Indian Pines的标签是1到16,而PyTorch的CrossEntropyLoss要求类别从0开始。所以我在__getitem__里做了y减1的操作。如果你在用9类版本,也要保证类别标号是连续的0到8,不能有跳号,否则计算损失时会报越界错误。

DataLoader的配置也值得说一下。batch_size建议用64或者128,shuffle设为True。num_workers在Windows下容易出多进程坑,可以设为0,Linux下可以适当调大到4或者8。pin_memory设为True可以加快GPU训练时数据从CPU传到GPU的速度。

train_dataset = HyperspectralDataset(X_train, y_train) test_dataset = HyperspectralDataset(X_test, y_test) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=0, pin_memory=True) test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=0, pin_memory=True)

把整个数据一次性放进Dataset是没问题的,因为高光谱小数据集完全能装进内存。千万不要用ImageFolder那一套去读图片文件,根本不需要,也不够高效。

3.3 训练循环与评估指标

训练部分的代码是比较标准的PyTorch训练流程,我建议把训练和验证分开写,每轮epoch结束后及时看验证集精度,方便判断是否过拟合。损失函数用CrossEntropyLoss,优化器用Adam,初始学习率设1e-3就足够。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN2D(in_channels=X.shape[1], num_classes=num_classes, patch_size=11).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) epochs = 60 best_acc = 0.0 for epoch in range(epochs): model.train() train_loss = 0.0 for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() output = model(x) loss = criterion(output, y) loss.backward() optimizer.step() train_loss += loss.item() * x.size(0) model.eval() correct = 0 total = 0 with torch.no_grad(): for x, y in test_loader: x, y = x.to(device), y.to(device) output = model(x) pred = output.argmax(dim=1) correct += (pred == y).sum().item() total += y.size(0) acc = correct / total if acc > best_acc: best_acc = acc torch.save(model.state_dict(), 'best_model.pth') print(f'Epoch {epoch+1}/{epochs} | Loss: {train_loss/len(train_dataset):.4f} | Test Acc: {acc:.4f}')

训练完之后,光看一个整体精度不太够。高光谱分类论文里最常用的是三个指标:OA、AA和Kappa系数。OA是全体的分类正确率,AA是每个类别分类精度的平均值,Kappa是衡量分类结果和随机分类之间差异性的指标,值越接近1代表分类一致性越好。想看这些指标,用sklearn.metrics里的classification_report和cohen_kappa_score就行。

from sklearn.metrics import classification_report, cohen_kappa_score # 在测试集上推理得到 pred_all 和 y_all print(classification_report(y_all, pred_all)) print('Kappa:', cohen_kappa_score(y_all, pred_all))

如果想把分类结果可视化,可以把模型对每个像素的预测类别填回145乘以145的网格里,用matplotlib的imshow画出来,背景类别设为白色,这样能直观看到预测地物分布和真实GT的差异。这也是高光谱分类项目里很出效果的一步,值得做一下。

4. 常见问题与实战提效经验

4.1 小样本过拟合:训练集很猛验证集拉胯

这个项目里最容易遇到的现象就是:训练集精度很快就到99%以上,但验证集或测试集只有80%左右。这就是典型的过拟合。高光谱标注像素少、数据维度高,模型很容易把训练集的细节当作通用规律记住。

解决过拟合不是一个方法就够的,而是组合拳。首先,可以在数据层面做增强,比如对patch做随机旋转和翻转。2D CNN对旋转比较敏感,做90度、180度旋转和水平翻转能非常自然地扩充样本量,而且几乎不改变地物类别信息。其次,可以加大Dropout比例,从0.5加到0.7,或者在全连接之前再加一层Dropout。第三,可以引入early stopping,当验证集精度连续多个epoch不提升时,直接停止训练,保存验证集最好的那一次模型。

我实测下来的体感是:PCA压缩到30维本身就是一个很强的正则化手段,如果发现过拟合严重,可以先试降到15维。有些项目用原始200个波段跑2D CNN,精度反而不如PCA降维之后,就是因为维度太高、样本太少,模型把噪声也学进去了。

训练集和测试集的划分方式也会直接影响对过拟合的判断。如果按像素随机划分,因为空间相似性,测试精度会偏高,看起来好像没过拟合,其实换一块区域效果就差很多。我建议至少在实验中设置好随机种子并用相同的划分方式做对比,这样模型结构和超参数之间的差异才可信。

4.2 显存不足、训练慢、损失不降怎么办

先说显存溢出。Indian Pines数据集很小,patch只有11乘以11,batch_size=64的情况下显存占用通常不会超过2G。如果你跑更大尺寸的数据集,比如Pavia University或者Houston数据,显存不够时优先减小batch_size,把64改成32或者16,通常问题就能解决。还不行就减小patch_size,从11改成9,输入通道数也会减少,模型更轻。

训练慢的问题要分情况看。如果是CPU训练,建议把num_workers设为0,避免线程切换的开销。如果是GPU训练但GPU利用率很低,大概率是数据加载成了瓶颈,这时候把num_workers调大,比如4或者8,并且确保数据预处理提前做完了,Dataset里不要每次做PCA之类的重计算。pin_memory=True也能减少数据传输时间。

损失不降或者训练一开始就NaN,这种问题我也遇到过几次。一个常见原因是学习率太大,Adam虽然对学习率不那么敏感,但初学率调到1e-2仍然可能让loss爆炸。另一个原因是类别标签不连续或者从1开始,CrossEntropyLoss内部会做索引查找,如果类别编号超过num_classes就会报错或产生诡异行为。还有,数据里有NaN值也会让loss变成NaN,建议预处理后检查data_pca里有没有非有限值,常用的手段是np.isnan(data_pca).any()打印一下。

高光谱数据的标注类别不平衡也是一个容易被忽视的问题。Indian Pines里像大豆这类样本很多,某些类别却只有几十个样本,模型天然偏向多数类。这时候可以给损失函数加上类别权重,权重与样本数量成反比,让少量类别在损失计算中获得更高的权重。

from sklearn.utils.class_weight import compute_class_weight class_weights = compute_class_weight('balanced', classes=np.unique(y_train), y=y_train) class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

4.3 让分类精度再涨一截的进阶小技巧

当基线2D CNN跑通之后,想进一步提升精度,有几个方向可以试,难度都不大。

第一个是调patch_size。patch_size决定了模型能看到多大的空间上下文。9、11、13、15这几个值都值得试一下。patch太小,空间信息不足;patch太大,会引入更多不相关区域,而且边界填充的伪数据也更多。Indian Pines上我一般从11开始试,上下浮动对比。

第二个是PCA的维度。我之前试过10、15、30、50四档,15到30之间的效果通常最好。维度太低会丢掉可分的细节光谱信息,维度太高又容易过拟合。这个参数可以和patch_size一起做网格搜索,用验证集的OA做选择依据。

第三个是更复杂的模型结构。如果2D CNN已经没问题了,可以尝试用两个分支分别提取光谱和空间特征,或者参考HybridSN的结构,把3D卷积和2D卷积结合起来。不过我的建议始终是:先保住2D基线,拿到一套稳定代码和结果,再逐步升级。很多同学一上来就HybridSN,网络结构复杂以后排查问题很痛苦,训练也慢,最后反而不如一个调得好的2D CNN。

另外,类别过滤也是提升整体精度的一个手段。Indian Pines原始16类里面,低于100个样本的类别对整体指标影响不大,但会拖低AA。做论文对比时用9类版本是常见做法,做工程项目时也可以根据实际需求合并相似类别,比如把几个草地类合成一个草地类。这个思路在真实遥感分类任务里也很实用,地物类别体系本来就是可设计的。

最后一个小建议:保存每次实验的配置和结果。高光谱实验涉及数据划分、patch大小、PCA维度、网络结构、学习率、随机种子等一堆变量,稍不注意就忘了当前结果对应哪组配置。我习惯用实验记录表把这些参数和对应的OA、AA、Kappa全部记录下来,后面写报告或者调参时才能心里有数。

我自己在实际操作中最深的体会是:高光谱图像分类这个任务,决定精度的往往不是模型多复杂,而是数据处理细节和实验规范程度。把归一化、PCA、patch提取、样本划分这些基础环节做扎实,一个简单的2D CNN就能取得相当不错的分类效果。先把这套链路稳定跑通,后面再加3D卷积、注意力机制,都是顺水推舟的事。你按这个流程复现一遍,再换成其他数据集跑一跑,很快就能上手整个高光谱深度学习项目。

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

嵌入式软件面试高频考点全解析:从C/C++基础到工程实践

面试这种事,说到底就是一场信息战。嵌入式软件方向的面试尤其如此,考察范围横跨C/C语法、操作系统原理、单片机底层、通信协议、工程化工具链,甚至还包括调试习惯和单元测试意识。我自己这些年既被人面过,也坐到桌对面看过不少候选…

作者头像 李华
网站建设 2026/9/30 8:00:40

C# 内存泄漏排查:从托管堆、非托管资源到 VS2022 诊断实战

1. 先把话说清楚:C# 里的内存泄漏到底长什么样 C# 有 GC,很多人第一反应是"托管代码哪来的内存泄漏"。这个说法只对了一半。GC 能回收的是"没有任何根引用指向的对象",它管不了"你还拿着引用却不打算再用"的对…

作者头像 李华
网站建设 2026/9/30 8:00:39

Qwen2.5-7B-Instruct中文微调实战:低显存高效训练与部署

简介:这是一份面向具备机器学习基础的开发者与研究人员的大模型微调实战指南,聚焦Qwen2.5-7B-Instruct在AutoDL平台上的全流程微调实践,覆盖环境配置、模型下载(含hf-mirror加速方案)、LLaMA-Factory部署、WebUI可视化…

作者头像 李华
网站建设 2026/9/30 7:59:19

Windows Server 2012 R2远程桌面多用户并发配置全指南

1. 为什么默认装完Windows Server 2012 R2根本不能多人同时远程登录?刚接手一台新部署的Windows Server 2012 R2物理机,客户急着要让5个部门主管同时远程接入做报表——我连RDP连接都还没点开,就发现事情不对劲:第一个用户登录后&…

作者头像 李华
网站建设 2026/9/30 7:58:42

H5项目秒变安卓App:HBuilderX打包Vue+Vant实战

开头 干前端的朋友应该都有过这种经历:需求方拿着一套H5页面,突然说“能不能给我做成App,我要装到手机上用”。你说重新用原生写?工期、成本、招聘样样不现实;你说套壳?怎么套、用什么套、能不能上架&#…

作者头像 李华
网站建设 2026/9/30 7:57:48

CSS ::marker伪元素实战:列表标记样式与content替换技巧

1. 项目概述1.1 从一个让我头疼的列表样式说起先讲个实际经历。前段时间我在做一个数据报表页面,后端返回一长串分类数据,我直接用无序列表渲染。产品经理看了一眼说:“这个项目符号太小了,颜色也和整体风格不搭。”我打开控制台&…

作者头像 李华