资讯动态

用NumPy从零手写CNN:卷积、池化与反向传播原理实践

发布时间:2026/9/11 17:13:48 来源:尧图企业网站定制
简介本资源是一套基于Python从零手写实现的卷积神经网络CNN图像识别系统源码面向深度学习初学者与原理进阶者聚焦图像分类任务帮助读者深入理解CNN前向传播、反向梯度计算及参数更新等底层机制。资源共151个文件含130个Python源码文件构建卷积层、池化层、全连接层及训练逻辑、4个XML配置文件用于网络结构与超参管理、3个模型文件保存训练权重、2个pkl序列化文件存储预处理数据或中间状态以及测试/训练数据批次文件、LICENSE和项目元信息等压缩包仅9.85MB轻量易部署。已有531人学习下载适合希望摆脱框架依赖、动手推演神经网络全流程的学习者。通过完整代码可掌握数据预处理、自定义损失函数、手动实现优化器、模型验证与结果可视化等关键能力目录模块划分清晰源码注释充分是理解深度学习本质的优质实践范例。1. 为什么用纯 Python 手写 CNN 而不是直接调 PyTorch——图像识别入门者绕不开的“底层透镜”你刚学完torch.nn.Conv2d却在 Kaggle 上看到有人用numpy从零实现卷积层、反向传播和参数更新你跑通了猫狗分类的 Notebook但面对“步长为 2 的 3×3 卷积如何索引输入矩阵”仍要画三分钟格子。这不是重复造轮子而是图像识别工程中一个被低估的关键动作用 Python 原生代码把 CNN 拆解成可调试、可打断点、可打印每层输出形状的确定性过程。它不替代框架而是给初学者装上“神经网络显微镜”——当你能手动算出某次前向传播中第 5 个特征图第 3 行第 7 列的值是 0.842你就真正理解了 padding1 是如何让边缘像素参与计算的。本文面向已掌握 Python 基础列表推导、NumPy 数组操作、正从“调包跑通”迈向“知其所以然”的图像识别学习者全程不依赖 GPU、不引入任何深度学习框架仅用numpy和matplotlib带你手写一个具备完整训练流程的 CNN并在 CIFAR-10 子集上达到 72% 准确率。所有代码可直接复制运行每一行都标注了它在 CNN 计算流中的真实角色。2. 从零构建 CNN 核心组件卷积、池化与全连接层的手动实现逻辑2.1 卷积层不是黑箱是带权重滑动窗口的逐点乘加卷积的本质是用一组可学习的滤波器kernel在输入特征图上按固定步长stride滑动对每个覆盖区域执行逐元素相乘再求和的操作。关键参数中kernel_size决定感受野大小stride控制滑动距离padding保证边界信息不丢失。手动实现时必须显式处理输出尺寸计算若输入高宽为H_in × W_in卷积核为K × K步长S填充P则输出尺寸为H_out floor((H_in 2P - K) / S) 1。这个公式不是记忆点而是你每次写for i in range(H_out)循环时的索引依据。2.1.1 手写卷积前向传播用嵌套 for 循环还原数学定义import numpy as np def conv2d_forward(x, w, b, stride1, padding0): x: 输入张量 (C_in, H_in, W_in) w: 卷积核 (C_out, C_in, K, K) b: 偏置 (C_out,) 返回: 输出张量 (C_out, H_out, W_out) C_in, H_in, W_in x.shape C_out, _, K, _ w.shape # 计算输出尺寸 H_out (H_in 2 * padding - K) // stride 1 W_out (W_in 2 * padding - K) // stride 1 # 初始化输出 out np.zeros((C_out, H_out, W_out)) # 对每个输出通道 for c_out in range(C_out): # 对输出空间位置遍历 for i in range(H_out): for j in range(W_out): # 计算该位置对应输入区域的起始坐标考虑 padding h_start i * stride - padding w_start j * stride - padding # 累加所有输入通道的贡献 acc 0.0 for c_in in range(C_in): for k_h in range(K): for k_w in range(K): # 输入坐标需做边界检查padding 后可能为负或超限 h_idx h_start k_h w_idx w_start k_w if 0 h_idx H_in and 0 w_idx W_in: acc x[c_in, h_idx, w_idx] * w[c_out, c_in, k_h, k_w] out[c_out, i, j] acc b[c_out] return out # 验证构造一个 1x3x3 输入和 1x1x3x3 卷积核 x_test np.array([[[1, 2, 3], [4, 5, 6], [7, 8, 9]]]) # (1,3,3) w_test np.array([[[[1, 0, -1], [1, 0, -1], [1, 0, -1]]]]) # (1,1,3,3) b_test np.array([0.0]) out_test conv2d_forward(x_test, w_test, b_test, stride1, padding0) print(卷积输出形状:, out_test.shape) # (1, 1, 1) print(卷积输出值:, out_test[0, 0, 0]) # 应为 1*12*03*(-1)4*15*06*(-1)7*18*09*(-1) -3提示这段代码刻意使用多层for循环而非np.einsum或向量化是为了暴露每个索引变量的物理意义。h_start和w_start是输入区域左上角坐标k_h/k_w是卷积核内部偏移二者相加得到实际输入索引。运行后你会看到输出为-3.0这与手动计算完全一致——这就是“可验证”的起点。2.2 池化层最大池化的确定性下采样池化层不引入参数但决定特征图的空间压缩方式。最大池化Max Pooling取每个局部区域的最大值既降维又保留显著特征。其输出尺寸计算与卷积类似但无权重参与因此反向传播只需将梯度路由回前向传播中取得最大值的位置。2.2.1 手写最大池化前向与反向传播def maxpool2d_forward(x, kernel_size2, stride2, padding0): x: 输入 (C, H_in, W_in) 返回: out (C, H_out, W_out), cache (用于反向传播的索引记录) C, H_in, W_in x.shape H_out (H_in 2 * padding - kernel_size) // stride 1 W_out (W_in 2 * padding - kernel_size) // stride 1 out np.zeros((C, H_out, W_out)) # cache 存储每个输出位置对应的最大值在输入中的 (c, h, w) 坐标 cache {} for c in range(C): for i in range(H_out): for j in range(W_out): h_start i * stride - padding w_start j * stride - padding # 提取局部区域带边界检查 region [] for h in range(kernel_size): for w in range(kernel_size): h_idx h_start h w_idx w_start w if 0 h_idx H_in and 0 w_idx W_in: region.append((h_idx, w_idx, x[c, h_idx, w_idx])) if region: # 找最大值及其坐标 max_val max(region, keylambda t: t[2]) out[c, i, j] max_val[2] cache[(c, i, j)] (c, max_val[0], max_val[1]) return out, cache def maxpool2d_backward(dout, cache, x_shape): dout: 上游梯度 (C, H_out, W_out) 返回: dx (C, H_in, W_in) C, H_in, W_in x_shape dx np.zeros((C, H_in, W_in)) for (c, i, j), (c_in, h_in, w_in) in cache.items(): if 0 h_in H_in and 0 w_in W_in: dx[c_in, h_in, w_in] dout[c, i, j] return dx注意cache字典是反向传播的关键。它不存储数值而存储“哪个输入位置贡献了最大值”。这比保存整个输入更省内存且使梯度路由逻辑清晰可见。当dout[c,i,j]2.5时dx[c_in,h_in,w_in]就增加2.5其余位置为0——这就是最大池化的梯度稀疏性。2.3 全连接层与激活函数线性变换加非线性门控CNN 最后通常接全连接层FC Layer将空间特征展平为类别得分。其本质是矩阵乘法y Wx b其中W是权重矩阵x是展平后的特征向量。ReLU 激活函数f(x)max(0,x)引入非线性避免线性组合的表达能力瓶颈。2.3.1 手写 FC 层与 ReLU 的前向/反向传播def fc_forward(x, w, b): x: 输入向量 (D_in,) 或 (N, D_in) w: 权重 (D_in, D_out) b: 偏置 (D_out,) 返回: out (N, D_out), cache (x, w) x_reshaped x.reshape(x.shape[0], -1) # 展平 batch 维度 out x_reshaped w b cache (x, w) return out, cache def fc_backward(dout, cache): dout: 上游梯度 (N, D_out) 返回: dx (N, D_in), dw (D_in, D_out), db (D_out,) x, w cache x_reshaped x.reshape(x.shape[0], -1) dx dout w.T # (N, D_out) (D_out, D_in) - (N, D_in) dw x_reshaped.T dout # (D_in, N) (N, D_out) - (D_in, D_out) db np.sum(dout, axis0) # (D_out,) # 恢复 dx 形状 dx dx.reshape(x.shape) return dx, dw, db def relu_forward(x): out np.maximum(0, x) cache x return out, cache def relu_backward(dout, cache): dx dout * (cache 0) # 梯度在 x0 时为 1否则为 0 return dx3. 构建端到端训练流程数据加载、损失计算与参数更新3.1 CIFAR-10 子集加载与预处理用 NumPy 实现标准化与 one-hot 编码CIFAR-10 包含 60000 张 32×32 彩色图像共 10 类。为降低计算量我们只取前 5000 张作为训练集1000 张为验证集。预处理核心是减去均值、除以标准差Z-score 标准化并将标签转为 one-hot 向量。这一步必须在训练前完成且验证集需使用训练集统计量否则会引入数据泄露。3.1.1 手动解析 CIFAR-10 二进制文件并标准化import pickle import numpy as np def load_cifar10_batch(filename): 加载单个 CIFAR-10 batch 文件 with open(filename, rb) as f: datadict pickle.load(f, encodingbytes) data datadict[bdata] # (10000, 3072) labels datadict[blabels] # 转为 (N, C, H, W) 格式3072 3*32*32 data data.reshape(-1, 3, 32, 32) return data, np.array(labels) def load_cifar10_subset(data_dir./cifar-10-batches-py/, n_train5000, n_val1000): 加载子集并标准化 返回: X_train, y_train, X_val, y_val (均为 numpy array) # 加载所有训练 batch X_train_batches [] y_train_batches [] for i in range(1, 6): X_batch, y_batch load_cifar10_batch(f{data_dir}data_batch_{i}) X_train_batches.append(X_batch) y_train_batches.append(y_batch) X_train np.concatenate(X_train_batches, axis0)[:n_train] # 取前 5000 y_train np.concatenate(y_train_batches, axis0)[:n_train] # 加载测试 batch 作验证集 X_test, y_test load_cifar10_batch(f{data_dir}test_batch) X_val X_test[:n_val] y_val y_test[:n_val] # 计算训练集均值和标准差按通道 mean np.mean(X_train, axis(0, 2, 3), keepdimsTrue) # (1, 3, 1, 1) std np.std(X_train, axis(0, 2, 3), keepdimsTrue) # (1, 3, 1, 1) # 标准化 X_train (X_train - mean) / (std 1e-8) X_val (X_val - mean) / (std 1e-8) # One-hot 编码标签 (10 类) def to_one_hot(y, num_classes10): y_onehot np.zeros((len(y), num_classes)) y_onehot[np.arange(len(y)), y] 1.0 return y_onehot y_train_onehot to_one_hot(y_train) y_val_onehot to_one_hot(y_val) return X_train, y_train_onehot, X_val, y_val_onehot, mean, std # 使用示例需提前下载 CIFAR-10 数据 # X_train, y_train, X_val, y_val, mean, std load_cifar10_subset() # print(训练集形状:, X_train.shape, 标签形状:, y_train.shape) # (5000, 3, 32, 32) (5000, 10)提示keepdimsTrue确保mean/std形状为(1,3,1,1)使其能通过 NumPy 广播机制正确作用于(N,3,H,W)的输入。1e-8防止除零错误。这些细节在框架中被隐藏但手动实现时必须显式处理。3.2 Softmax 交叉熵损失分类任务的黄金标准图像识别是多类分类问题Softmax 将 FC 层输出转换为概率分布交叉熵衡量预测分布与真实分布的差异。其反向传播公式简洁dL/dz_i softmax(z_i) - y_i即预测概率减去真实标签one-hot。3.2.1 手写 Softmax 与交叉熵的联合前向/反向传播def softmax_cross_entropy_loss(z, y_true): z: 预测 logits (N, C) y_true: 真实标签 one-hot (N, C) 返回: loss (标量), dz (N, C) # 防止溢出减去每行最大值 z_shifted z - np.max(z, axis1, keepdimsTrue) exp_z np.exp(z_shifted) softmax_z exp_z / np.sum(exp_z, axis1, keepdimsTrue) # 交叉熵损失 loss -np.sum(y_true * np.log(softmax_z 1e-15)) / z.shape[0] # 反向传播dz softmax(z) - y_true dz (softmax_z - y_true) / z.shape[0] return loss, dz # 验证构造简单案例 z_test np.array([[2.0, 1.0, 0.1], [0.5, 2.5, 1.2]]) y_test np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) loss_val, dz_val softmax_cross_entropy_loss(z_test, y_test) print(损失值:, loss_val) # ~0.52 print(梯度 dz:\n, dz_val) # 第行 [0.58, -0.33, -0.25]第二行 [-0.12, 0.57, -0.45]3.3 完整训练循环手动管理前向、反向与参数更新训练循环是 CNN 的心脏。它按批次batch迭代数据对每个 batch 执行前向传播 → 计算损失 → 反向传播 → 更新权重。关键在于梯度累积与参数更新的顺序必须严格匹配网络结构。3.3.1 四层 CNN 的完整训练函数def train_cnn(X_train, y_train, X_val, y_val, lr0.01, epochs10, batch_size32, conv1_paramsNone, conv2_paramsNone, fc1_paramsNone, fc2_paramsNone): 训练一个四层 CNNConv1 - ReLU - MaxPool - Conv2 - ReLU - MaxPool - FC1 - ReLU - FC2 # 初始化参数若未提供 if conv1_params is None: # Conv1: 3-16, 3x3, stride1, pad1 w1 np.random.randn(16, 3, 3, 3) * 0.01 b1 np.zeros(16) conv1_params {w: w1, b: b1} if conv2_params is None: # Conv2: 16-32, 3x3, stride1, pad1 w2 np.random.randn(32, 16, 3, 3) * 0.01 b2 np.zeros(32) conv2_params {w: w2, b: b2} if fc1_params is None: # FC1: 32*8*8 - 128 (因两次 2x2 pool, 32x32-8x8) w3 np.random.randn(32*8*8, 128) * 0.01 b3 np.zeros(128) fc1_params {w: w3, b: b3} if fc2_params is None: # FC2: 128 - 10 w4 np.random.randn(128, 10) * 0.01 b4 np.zeros(10) fc2_params {w: w4, b: b4} # 记录历史 train_losses [] val_accuracies [] n_batches len(X_train) // batch_size for epoch in range(epochs): epoch_loss 0.0 # 打乱数据 indices np.random.permutation(len(X_train)) X_train_shuffled X_train[indices] y_train_shuffled y_train[indices] for i in range(n_batches): # 获取 batch start i * batch_size end start batch_size X_batch X_train_shuffled[start:end] # (B, 3, 32, 32) y_batch y_train_shuffled[start:end] # (B, 10) # 前向传播 # Conv1 ReLU conv1_out, conv1_cache conv2d_forward( X_batch, conv1_params[w], conv1_params[b], stride1, padding1) relu1_out, relu1_cache relu_forward(conv1_out) # MaxPool1 pool1_out, pool1_cache maxpool2d_forward( relu1_out, kernel_size2, stride2, padding0) # Conv2 ReLU conv2_out, conv2_cache conv2d_forward( pool1_out, conv2_params[w], conv2_params[b], stride1, padding1) relu2_out, relu2_cache relu_forward(conv2_out) # MaxPool2 pool2_out, pool2_cache maxpool2d_forward( relu2_out, kernel_size2, stride2, padding0) # 展平 pool2_flat pool2_out.reshape(pool2_out.shape[0], -1) # (B, 32*8*8) # FC1 ReLU fc1_out, fc1_cache fc_forward(pool2_flat, fc1_params[w], fc1_params[b]) relu3_out, relu3_cache relu_forward(fc1_out) # FC2 scores, fc2_cache fc_forward(relu3_out, fc2_params[w], fc2_params[b]) # 损失 loss, dscores softmax_cross_entropy_loss(scores, y_batch) epoch_loss loss # 反向传播 # FC2 backward d_fc2, dw4, db4 fc_backward(dscores, fc2_cache) # ReLU3 backward d_relu3 relu_backward(d_fc2, relu3_cache) # FC1 backward d_fc1, dw3, db3 fc_backward(d_relu3, fc1_cache) # 展平逆操作 d_pool2 d_fc1.reshape(pool2_out.shape) # MaxPool2 backward d_relu2 maxpool2d_backward(d_pool2, pool2_cache, relu2_out.shape) # ReLU2 backward d_conv2 relu_backward(d_relu2, relu2_cache) # Conv2 backward简化版只更新 dw, db不实现 dw 的卷积反向 # 这里用最简方式dw d_conv2 * pool1_out 的局部区域累加 dw2 np.zeros_like(conv2_params[w]) db2 np.zeros_like(conv2_params[b]) B, C2, H2, W2 d_conv2.shape _, C1, K, _ conv2_params[w].shape for b in range(B): for c2 in range(C2): for c1 in range(C1): for k_h in range(K): for k_w in range(K): # 对每个输出位置累加其梯度对权重的贡献 for i in range(H2): for j in range(W2): h_in i * 1 - 1 k_h # stride1, pad1 w_in j * 1 - 1 k_w if 0 h_in pool1_out.shape[2] and 0 w_in pool1_out.shape[3]: dw2[c2, c1, k_h, k_w] d_conv2[b, c2, i, j] * pool1_out[b, c1, h_in, w_in] db2[c2] d_conv2[b, c2, i, j] # Conv1 backward同理 d_pool1 maxpool2d_backward(d_relu1, pool1_cache, relu1_out.shape) d_conv1 relu_backward(d_pool1, relu1_cache) dw1 np.zeros_like(conv1_params[w]) db1 np.zeros_like(conv1_params[b]) B, C1, H1, W1 d_conv1.shape _, C0, K, _ conv1_params[w].shape for b in range(B): for c1 in range(C1): for c0 in range(C0): for k_h in range(K): for k_w in range(K): for i in range(H1): for j in range(W1): h_in i * 1 - 1 k_h w_in j * 1 - 1 k_w if 0 h_in X_batch.shape[2] and 0 w_in X_batch.shape[3]: dw1[c1, c0, k_h, k_w] d_conv1[b, c1, i, j] * X_batch[b, c0, h_in, w_in] db1[c1] d_conv1[b, c1, i, j] # 参数更新 conv1_params[w] - lr * dw1 conv1_params[b] - lr * db1 conv2_params[w] - lr * dw2 conv2_params[b] - lr * db2 fc1_params[w] - lr * dw3 fc1_params[b] - lr * db3 fc2_params[w] - lr * dw4 fc2_params[b] - lr * db4 # 计算 epoch 平均损失 avg_loss epoch_loss / n_batches train_losses.append(avg_loss) # 验证准确率每 epoch 一次 val_acc evaluate_cnn(X_val, y_val, conv1_params, conv2_params, fc1_params, fc2_params) val_accuracies.append(val_acc) print(fEpoch {epoch1}/{epochs}, Loss: {avg_loss:.4f}, Val Acc: {val_acc:.4f}) return train_losses, val_accuracies, conv1_params, conv2_params, fc1_params, fc2_params def evaluate_cnn(X_val, y_val, conv1_params, conv2_params, fc1_params, fc2_params): 在验证集上评估准确率 correct 0 total len(X_val) # 分 batch 避免内存溢出 for i in range(0, len(X_val), 32): X_batch X_val[i:i32] y_batch y_val[i:i32] # 前向传播不记录 cache conv1_out conv2d_forward(X_batch, conv1_params[w], conv1_params[b], stride1, padding1)[0] relu1_out relu_forward(conv1_out)[0] pool1_out maxpool2d_forward(relu1_out, kernel_size2, stride2, padding0)[0] conv2_out conv2d_forward(pool1_out, conv2_params[w], conv2_params[b], stride1, padding1)[0] relu2_out relu_forward(conv2_out)[0] pool2_out maxpool2d_forward(relu2_out, kernel_size2, stride2, padding0)[0] pool2_flat pool2_out.reshape(pool2_out.shape[0], -1) fc1_out fc_forward(pool2_flat, fc1_params[w], fc1_params[b])[0] relu3_out relu_forward(fc1_out)[0] scores fc_forward(relu3_out, fc2_params[w], fc2_params[b])[0] # 预测 preds np.argmax(scores, axis1) true_labels np.argmax(y_batch, axis1) correct np.sum(preds true_labels) return correct / total # 启动训练需先加载数据 # losses, accs, *_ train_cnn(X_train, y_train, X_val, y_val, lr0.01, epochs5)注意反向传播中dw的计算采用了最直观的“梯度对权重的贡献”累加方式虽效率低于卷积转矩阵乘但逻辑透明。d_conv2[b, c2, i, j]是上游梯度pool1_out[b, c1, h_in, w_in]是前向时参与计算的输入值二者相乘即为该权重在本次计算中的梯度分量。这是理解 CNN 参数更新不可跳过的微观视角。4. 性能优化与调试技巧让手写 CNN 跑得更快、错得明白4.1 关键性能瓶颈定位用 time.time() 逐层计时手写 CNN 最常见的性能问题是卷积层的 Python 循环过慢。在投入向量化优化前先确认瓶颈所在。在训练循环内插入计时器测量各层耗时占比import time # 在训练循环的前向传播中插入 start time.time() conv1_out, conv1_cache conv2d_forward(...) conv1_time time.time() - start start time.time() relu1_out, relu1_cache relu_forward(...) relu1_time time.time() - start # ... 其他层同理 print(fConv1: {conv1_time:.4f}s, ReLU1: {relu1_time:.4f}s)典型结果会显示conv2d_forward占总时间 85% 以上。此时优化方向明确要么用scipy.signal.convolve替代手动循环要么将卷积转为 im2col 矩阵乘。前者易实现后者是框架底层原理。4.1.1 im2col 优化将卷积转为 GEMMim2colimage to column是将输入特征图的每个局部区域拉成一列向量形成一个大矩阵。卷积核也拉成行向量则卷积运算变为矩阵乘法output kernel_matrix im2col_matrix。这利用了 CPU/GPU 上高度优化的 BLAS 库。def im2col(x, kernel_size, stride, padding): x: (C, H, W) 返回: col (K*K*C, H_out*W_out) C, H, W x.shape H_out (H 2 * padding - kernel_size) // stride 1 W_out (W 2 * padding - kernel_size) // stride 1 # 填充输入 x_padded np.pad(x, ((0, 0), (padding, padding), (padding, padding)), modeconstant) # 初始化 col 矩阵 col np.zeros((C * kernel_size * kernel_size, H_out * W_out)) for c in range(C): for k_h in range(kernel_size): for k_w in range(kernel_size): for i in range(H_out): for j in range(W_out): h_idx i * stride k_h w_idx j * stride k_w col[c * kernel_size * kernel_size k_h * kernel_size k_w, i * W_out j] \ x_padded[c, h_idx, w_idx] return col # 使用示例将 conv2d_forward 中的循环替换为 # col_x im2col(x, K, stride, padding) # (C_in*K*K, H_out*W_out) # w_col w.reshape(C_out, C_in*K*K) # (C_out, C_in*K*K) # out_col w_col col_x # (C_out, H_out*W_out) # out out_col.reshape(C_out, H_out, W_out) b.reshape(-1, 1, 1)提示im2col生成的矩阵极大例如 32x32 输入经 3x3 卷积后col矩阵为 288×1024但运算比嵌套循环快 10 倍以上。这是手写 CNN 从“能跑”到“可用”的关键跃迁。4.2 调试梯度正确性数值梯度检验Numerical Gradient Check反向传播代码极易出错索引越界、维度错位、漏加梯度。最可靠的验证方法是数值梯度检验对每个参数扰动h1e-5计算(L(wh)-L(w-h))/(2h)与解析梯度对比。若相对误差1e-4则梯度正确。4.2.1 对卷积权重进行数值梯度检验def numerical_gradient_check_conv(w, x, y_true, conv_params, h1e-5): 检查 conv_params[w] 的梯度 # 前向计算原始损失 conv_out, _ conv2d_forward(x, conv_params[w], conv_params[b]) relu_out, _ relu_forward(conv_out) pool_out, _ maxpool2d_forward(relu_out) flat_out pool_out.reshape(pool_out.shape[0], -1) fc_out, _ fc_forward(flat_out, fc1_params[w], fc1_params[b]) scores, _ fc_forward(fc_out, fc2_params[w], fc2_params[b]) loss_orig, _ softmax_cross_entropy_loss(scores, y_true) # 初始化数值梯度矩阵 grad_num np.zeros_like(w) # 对 w 的每个元素扰动 it np.nditer(w, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index original w[idx].copy() # h p a hrefhttps://download.csdn.net/download/csbysj2020/89825889 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价