数学建模社区-数学中国

标题: Pytorch实战语义分割(VOC2012) [打印本页]

作者: 杨利霞    时间: 2020-5-4 15:03
标题: Pytorch实战语义分割(VOC2012)
8 ^8 T7 F8 g( m- W  i2 p

& r& L4 G1 K# }Pytorch实战语义分割(VOC2012)1 T. p  d) i0 F7 D! w/ n5 R7 i
本文参照了《动手深度学习》的9.9、9.10章节,原书使用的是 mxnet 框架,本文改成了pytorch代码。* ]  ^% u$ }  O6 B. S
语义分割(semantic segmentation)问题,它关注如何将图像分割成属于不同语义类别的区域。值得一提的是,这些语义区域的标注和预测都是像素级的。: t* s, L; ?9 p& H/ ^
2 X# E" z6 l: s

* S3 X. _0 u% I8 ~8 X" Q语义分割中图像有关狗、猫和背景的标签
' p4 P& F* n$ l. N! c文章目录' W9 i, r: S" J9 o; C6 ~, ?( Q5 h

- z4 |, E2 O3 C" [1 图像分割和实例分割" b  S3 d. w1 w8 o; G. R
2 Pascal VOC2012语义分割数据集2 d. F+ Y9 w/ u* q3 w
2.1 导入模块
# v* E% t8 R' s0 P5 y& t. R2.2 下载数据集
% K/ Q& o, i+ T  L2.3 可视化数据9 j) U4 h- V. b+ Y2 K% ?" F8 C
2.4 预处理数据. E! X6 k5 q; D! ?- L: O4 E# F
3 自定义数据集类
0 W$ a; P; N& Z3 ?) W3.1 数据集类" C9 z2 d2 t. Z3 Q4 Y: p2 k# ?0 a/ J# o
3.2 读取数据集
+ a9 ^1 N: S: D% W6 _* G4 构造模型
# b$ J8 D6 N" \& z4.1 预训练模型3 d: \0 r6 \; {* F# y0 s
4.2 修改成FCN
1 c& {# w' u- u' N4.3 初始化转置卷积层
. g+ y/ J. ]+ [5 a6 p5 训练模型
: w  r# J* b; l! [6 h+ c6 测试模型
( Z2 ]( |( Q: [6.1 通用型9 i, ^8 H, R, p- r+ Q6 p! S
6.2 不通用
& |9 ^$ p5 u& Z. p7 结语
( W' w& M6 F" ]- V1 图像分割和实例分割+ o' i' w' z" |0 I& B; ]; o
" M8 e0 I: c8 Z/ M/ V4 j0 L, T- d
计算机视觉领域还有2个与语义分割相似的重要问题,即图像分割(image segmentation)和实例分割(instance segmentation):. M1 r3 p2 R4 m, S. J) U

# |+ y: X  b/ I7 X: R图像分割将图像分割成若干组成区域。这类问题的方法通常利用图像中像素之间的相关性。它在训练时不需要有关图像像素的标签信息,在预测时也无法保证分割出的区域具有我们希望得到的语义。以上图的图像为输入,图像分割可能将狗分割成两个区域:一个覆盖以黑色为主的嘴巴和眼睛,而另一个覆盖以黄色为主的其余部分身体。
- ^: l* n% O/ c1 x实例分割又叫同时检测并分割(simultaneous detection and segmentation)。它研究如何识别图像中各个目标实例的像素级区域。与语义分割有所不同,实例分割不仅需要区分语义,还要区分不同的目标实例。如果图像中有两只狗,实例分割需要区分像素属于这两只狗中的哪一只。
7 k! K' z2 g8 y# p" |' T6 X
1 q6 u& x! B  M/ b7 F2 Pascal VOC2012语义分割数据集! s: Z: Q, w4 W1 X  ]

. l  s1 X8 e3 V/ F; L2.1 导入模块/ J! t( m5 ?# L0 |
import time
( U- \  J$ |- D1 I! Timport copy
( r- K5 c: d9 C2 \" M0 j4 uimport torch: @. ~5 u( w6 z9 u
from torch import optim, nn" Y2 V6 X# F% S$ `  _9 r  N
import torch.nn.functional as F* ^  S1 }  ^! H1 J4 Z
import torchvision6 V4 Z5 V% [  s* K( N
from torchvision import transforms/ W# u- J0 F7 q2 C4 O
from torchvision.models import resnet18) Q/ ^$ W& X9 [8 H4 n3 g5 b7 I
import numpy as np+ L- x' O/ e$ G0 H
from matplotlib import pyplot as plt
& P$ y; [& z' f+ d0 t0 ^* Afrom PIL import Image1 e2 ~. n8 E. E; Q. c- e2 W- _
import sys) A) l' C% ?  H/ V
sys.path.append("..")4 m) z/ |! R3 ~- S
from IPython import display# Q; j. g) i* Y) R8 t8 K
from tqdm import tqdm
) S5 `2 L, e: \) I5 ]6 Wimport warnings$ U' a% A5 U6 X* W# G* Y
warnings.filterwarnings("ignore")8 ^5 o$ m+ I9 p
* T6 G: W% B9 p6 x. P
2.2 下载数据集' w- s% e% l" z2 i# r( P

3 `7 G# b0 T9 B! ~% c语义分割的一个重要数据集叫作Pascal VOC2012,点击下载这个数据集的压缩包,大小是2 GB左右,所以下载需要一定时间。下载后解压得到VOCdevkit/VOC2012文件夹,然后将其放置在data文件夹下,VOC2012文件目录是这样的:! Q9 z/ }; I" W. X* E4 B0 v0 j

' A: U0 S; |# s7 a5 j6 Q2 i6 {1 s0 ]# L6 m$ P  M  X9 h7 U
ImageSets/Segmentation路径包含了指定训练和测试样本的文本文件- S+ N' z. M, \! f% \5 \" i
JPEGImages和SegmentationClass路径下分别包含了样本的输入图像和标签。这里的标签也是图像格式,其尺寸和它所标注的输入图像的尺寸相同。标签中颜色相同的像素属于同一个语义类别。3 w  h) j" _* m  \. M' e
2.3 可视化数据6 \& J+ Q& Y0 R# h9 `! q

& Q8 H% K! a2 _- s8 C: G定义read_voc_images函数将输入图像和标签读进内存。
0 T) y* v8 L) f+ H
7 b, z- }! v  u9 c8 _def read_voc_images(root="../../data/VOCdevkit/VOC2012", is_train=True, max_num=None):$ T! q% w. @1 ^" g9 s) t+ o5 B  o
    txt_fname = '%s/ImageSets/Segmentation/%s' % (root, 'train.txt' if is_train else 'val.txt')
$ X  P* v: \) t4 Z0 x" S* ~, W( Q    with open(txt_fname, 'r') as f:1 s. k3 \2 n: W3 R# W' J
        images = f.read().split() # 拆分成一个个名字组成list
# g: _2 @  a2 p  Z0 a1 p    if max_num is not None:
% {8 R$ O9 s" `/ l; |) {        images = images[:min(max_num, len(images))]
, Z' O: }5 z! K4 N0 e- ^1 M9 a    features, labels = [None] * len(images), [None] * len(images)! W, }+ `* R/ a2 o1 S
    for i, fname in tqdm(enumerate(images)):
1 t2 l2 E9 M1 `        # 读入数据并且转为RGB的 PIL image' [2 R6 [: `9 _. E9 R% R$ v
        features = Image.open('%s/JPEGImages/%s.jpg' % (root, fname)).convert("RGB")
* ]* [" g2 J, p0 A- a  e& K" {- \        labels = Image.open('%s/SegmentationClass/%s.png' % (root, fname)).convert("RGB"). l$ C1 Q! R, S9 Z* }2 R: R4 Q8 a
    return features, labels # PIL image 0-255: F# w5 v( I# }% [5 Y9 L
* z8 \9 U/ k9 f0 J. e' |8 \3 a( M. a
定义可视化数据集的函数show_images: N) l5 W: J8 }' u/ }, ]
+ R0 k2 `; x2 M/ q  R* P4 r7 R
# 这个函数可以不需要
; k& U$ l7 \* rdef set_figsize(figsize=(3.5, 2.5)):% d* l% w4 U) s* E; f
    """在jupyter使用svg显示""". n3 C! ^5 L0 L1 J
    display.set_matplotlib_formats('svg')
7 k' D7 ~$ ?8 z- N+ y! g    # 设置图的尺寸3 g: w; `$ t8 m
    plt.rcParams['figure.figsize'] = figsize7 f  x9 g5 T% q, u2 c
& ?& I( p; ^, E, t
def show_images(imgs, num_rows, num_cols, scale=2):6 X: p) c5 a4 w* ]: B; K! ^
    # a_img = np.asarray(imgs)% F  T  Z: A$ y0 V. k' V5 g
    figsize = (num_cols * scale, num_rows * scale)
# Z" S" n$ m% A, Q& C( Y. e2 E5 L    _, axes = plt.subplots(num_rows, num_cols, figsize=figsize)
9 J; L' A- m6 u7 d; H" k$ C    for i in range(num_rows):2 o$ w6 X' u+ \( }
        for j in range(num_cols):  A$ ?9 \9 l4 A' ]: a1 [
            axes[j].imshow(imgs[i * num_cols + j])5 Q8 u6 N% t1 g' l! R4 t- C9 _8 J5 `
            axes[j].axes.get_xaxis().set_visible(False)* m" `' x/ R3 A# P4 ]( d6 h
            axes[j].axes.get_yaxis().set_visible(False)
6 c) y0 a0 o! R+ [( w/ O    plt.show()8 S8 f% H+ y+ ^8 O6 t
    return axes
# Z' }6 w( E! m5 d7 f8 f6 [, A2 H6 O. t$ i8 G! F7 k
定义可视化数据集的函数show_images6 [9 C5 X& {% a/ L9 p1 K- J5 y
+ x  h$ v+ Z3 {. |/ }. l$ r
# 这个函数可以不需要7 A  U& X9 ~4 C2 Q9 D: P. a
def set_figsize(figsize=(3.5, 2.5)):, r2 v) N; o! ]& ?1 J, P/ c
    """在jupyter使用svg显示"""
. Q" n* C( E+ A0 _2 _/ H; i/ w    display.set_matplotlib_formats('svg')3 R0 n1 ?' e, D4 x; e0 _1 j: R
    # 设置图的尺寸
3 \9 \$ m) Q+ `# I7 G/ a    plt.rcParams['figure.figsize'] = figsize
  x6 M0 i1 b5 {! I
9 r# N* V4 ~9 j5 Zdef show_images(imgs, num_rows, num_cols, scale=2):
3 }4 |4 a& h+ B( P% G    # a_img = np.asarray(imgs). F% N/ ?$ e8 r5 v
    figsize = (num_cols * scale, num_rows * scale)  c* R; Z2 R' }5 a; ?3 \
    _, axes = plt.subplots(num_rows, num_cols, figsize=figsize)
! g% V5 F# i0 w# M! e    for i in range(num_rows):  r# _! X2 C/ @2 r7 a5 [% T8 F
        for j in range(num_cols):8 {5 I9 P' D. p) \9 r
            axes[j].imshow(imgs[i * num_cols + j])3 _9 P  n4 ~& B. C; [
            axes[j].axes.get_xaxis().set_visible(False)
* e2 H3 q4 T; B/ P1 K4 F. l            axes[j].axes.get_yaxis().set_visible(False)' e" r& o) Z& y  L4 m
    plt.show()
1 l3 @) f! v" s; V. K8 a# t+ V    return axes
7 Y8 O$ Z( ~: F) q( R4 J画出前5张输入图像和它们的标签。在标签图像中,白色和黑色分别代表边框和背景,而其他不同的颜色则对应不同的类别。# Q* ^! k6 p. V9 z) @6 t
+ i6 P3 t0 f( e1 [2 Q2 j
# 根据自己存放数据集的路径修改voc_dir
0 m  z4 v" r' f& F, Kvoc_dir = r"[local]\VOCdevkit\VOC2012"; @9 N1 a% g: n  X8 d+ `, Y
train_features, train_labels = read_voc_images(voc_dir, max_num=10)# L3 V5 B( H  P3 x3 v( l; c
n = 5 # 展示几张图像
+ [/ |0 u4 {. F" `+ M1 f+ C1 uimgs = train_features[0:n] + train_labels[0:n] # PIL image' q- _  f4 r, y# ^, w. |: ?9 W
show_images(imgs, 2, n)3 V1 W9 [5 Z, d/ t0 {0 ~
. ^# ^( \, R% T3 N# J$ U/ n( @
1.png
5 _4 \# H/ ~( G, {, S
+ H: i: S" J% a" T% {列出标签中每个RGB颜色的值及其标注的类别。/ B6 c, u+ S  {0 a
# 标签中每个RGB颜色的值" m; [. j$ ]4 D( |* o5 m
VOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],
0 G2 W/ `$ k' }0 R% ^6 W. g                [0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],3 Z0 Y  W5 g0 O$ Z/ e1 \
                [64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],8 y/ n; V6 |: u* i9 \
                [64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],
0 ^/ b: i5 [) w# t                [0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],# M: q8 P# ^5 E6 p, E
                [0, 64, 128]]
0 \9 u; S: L: N; M# 标签其标注的类别" C  [7 \1 C4 s: {9 {3 l% r7 I
VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat',3 r, S1 R7 r7 G  j/ R, R$ }7 K9 `5 u0 l
               'bottle', 'bus', 'car', 'cat', 'chair', 'cow',
+ Y) V% E2 J& p               'diningtable', 'dog', 'horse', 'motorbike', 'person',* @  T9 H; u; Y8 a4 O( q
               'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']2 v, K  F  k( f
有了上面定义的两个常量以后,我们可以很容易地查找标签中每个像素的类别索引voc_label_indices是根据colormap2label把标签里的 rgb 颜色对应上面的VOC_COLORMAP中的下标给取出来,当作 label 。
1 f  Q5 {/ ^, |) M9 @5 b' ]) O: |+ V+ W8 i* T
有了上面定义的两个常量以后,我们可以很容易地查找标签中每个像素的类别索引voc_label_indices是根据colormap2label把标签里的 rgb 颜色对应上面的VOC_COLORMAP中的下标给取出来,当作 label 。
' h0 `0 ~( [/ G+ g: L1 g) Pcolormap2label = torch.zeros(256**3, dtype=torch.uint8) # torch.Size([16777216])
3 K6 L) f% H  n2 R  sfor i, colormap in enumerate(VOC_COLORMAP):+ X7 o1 G; i( v
    # 每个通道的进制是256,这样可以保证每个 rgb 对应一个下标 i
0 Y; x+ B3 Q& |! e/ h    colormap2label[(colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i
3 P4 f/ w* S9 }: D$ V. G& j6 M; r0 e' B& V/ M, X& G( Q
# 构造标签矩阵, k! C4 j1 b, L: t, i1 d* q; E
def voc_label_indices(colormap, colormap2label):
8 R1 Z9 U, B' a$ [: M    colormap = np.array(colormap.convert("RGB")).astype('int32')
) L3 U0 `( s+ P( [6 Q    idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256 + colormap[:, :, 2]) 7 Z1 R5 g1 W9 y" \" O
    return colormap2label[idx] # colormap 映射 到colormaplabel中计算的下标
  \9 O. Y0 ]! B, D
0 W2 W  r% |% x6 h- i7 B( i# c可以打印一下结果5 G- Z& W, w9 _& r/ D
5 ^9 d7 ^7 d% v) R: S
y = voc_label_indices(train_labels[0], colormap2label)8 x: F, f7 m1 l! O) b) d
print(y[100:110, 130:140]) #打印结果是一个int型tensor,tensor中的每个元素i表示该像素的类别是VOC_CLASSES5 ?- l! I0 X$ D& u) F" C! l
3 _/ O# a8 F& C9 h" S
2.4 预处理数据
: f5 |4 O( J& T, B! a. F( _
) f/ u1 M1 y) q$ \1 `1 @" F在语义分割里,如果使用缩放图像使其符合模型的输入形状的话,需要将预测的像素类别重新映射回原始尺寸的输入图像,这样的映射难以做到精确,尤其是在不同语义的分割区域。所以选择将图像裁剪成固定尺寸而不是缩放。具体来说,我们使用图像增广里的随机裁剪,并对输入图像和标签裁剪相同区域。, l8 O! M+ m7 r3 `+ d

* ]3 [9 j6 j0 k7 U' A( U2 y
' p8 ]+ N. c( g" B3 @) X9 Ydef voc_rand_crop(feature, label, height, width):+ @' y; L8 `# W2 B2 w
    """+ ?) g  P) U* k: H4 Y, e3 ~
    随机裁剪feature(PIL image) 和 label(PIL image).
: q3 p9 |4 p9 b- v# ?& s/ B    为了使裁剪的区域相同,不能直接使用RandomCrop,而要像下面这样做0 I8 {+ n& C8 q- K
    Get parameters for ``crop`` for a random crop.
# ?0 d% t' s% H) J' _% H    Args:
1 ~/ M& g. \4 V, h' r$ v1 B% d        img (PIL Image): Image to be cropped.
6 E& J6 t" `2 _6 Q# _! J9 ^/ m# k* T, ^        output_size (tuple): Expected output size of the crop.& C0 {/ I0 R0 o1 w2 u% [( U; m
    Returns:# U4 _4 u  ]3 M; f
        tuple: params (i, j, h, w) to be passed to ``crop`` for random crop.' g! Z) `- `1 u) R8 q, b0 V0 P
    """
9 u1 D2 U7 t3 c! {2 `3 y5 B    i,j,h,w = torchvision.transforms.RandomCrop.get_params(feature, output_size=(height, width))$ A# G9 w/ i' {/ o% Q+ T, t
    feature = torchvision.transforms.functional.crop(feature, i, j, h, w)8 s1 h8 s0 {. D' _( g; e
    label = torchvision.transforms.functional.crop(label, i, j, h, w)- y" f8 H. W/ u. r3 k2 T0 o
    return feature, label
, y: y  J2 e( z# J$ w/ P1 [
/ j$ I2 A8 {. o9 W# 显示n张随机裁剪的图像和标签,前面的n是5* C" z4 _# f9 y: @' c
imgs = []
8 ~6 O3 I9 a( j/ E2 I7 q, gfor _ in range(n):
+ ^3 _* [$ o7 D9 i  V: R    imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)
: I' m8 _1 s" E! dshow_images(imgs[::2] + imgs[1::2], 2, n);$ b7 X1 C  Z7 G% t! M

( [4 T2 `& Z9 F2 n. ?% \8 r! E/ l
, l3 A' ^5 c5 X' ` 2.png 3 |6 v! m0 @+ x/ [7 O6 u' C- U
7 i. L" C8 ~- S7 c9 q

0 B7 B+ P4 F& b: r& n- L
/ ?0 J- ?1 e4 P* u; f6 X3 自定义数据集类0 T& S1 l4 ?% b* h# [

9 A* S% Y3 [) L3.1 数据集类* {# L2 P+ X4 q! K1 V. P! W! L' M

5 f2 G* u2 g% j: S+ U5 L  Ptorch.utils.data.Dataset是表示数据集的抽象类,因此自定义数据集应继承Dataset并覆盖以下方法  D! S  _* U( _5 ?

2 J& k* B9 l3 K. w  h__len__ 实现 len(dataset) 返还数据集的尺寸。
$ V& ^0 c8 e1 L; h__getitem__用来获取一些索引数据,例如 dataset[idx] 中的(idx)。
9 ]! j$ H8 t$ `' \* U$ E% W0 |0 R由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本需要通过自定义的filter函数所移除。此外,因为之后会用到预训练模型来做特征提取器,所以我们还对输入图像的 RGB 三个通道的值分别做标准化。
: Y4 V5 }, W6 G8 @3 A/ X
; U* r7 W" R1 y: Y. Y& j  q( T: n- w, ?class VOCSegDataset(torch.utils.data.Dataset):
) j: S+ Z, P3 a; v3 t    def __init__(self, is_train, crop_size, voc_dir, colormap2label, max_num=None):
. Y- Z0 J- Z) @( X        """
: L/ }: f# ?5 z! m        crop_size: (h, w)
; w6 c7 w- G& B' g% K$ M3 T' H        """  d  p4 I9 C1 f
        # 对输入图像的RGB三个通道的值分别做标准化
* X  G- l, L! W2 h        self.rgb_mean = np.array([0.485, 0.456, 0.406])
) `  [$ ]( Z6 k7 i3 z        self.rgb_std = np.array([0.229, 0.224, 0.225])
/ y& B6 Y* P2 n8 w        self.tsf = torchvision.transforms.Compose([& a4 m' k% ^9 N: t4 M7 O- @
            torchvision.transforms.ToTensor(),: m' B* ~4 P$ i# }, Y. r9 q. [1 b8 G
            torchvision.transforms.Normalize(mean=self.rgb_mean, std=self.rgb_std)])
1 m* [# B) d2 N# }        self.crop_size = crop_size # (h, w)
5 Z" \) X  @: B1 H        features, labels = read_voc_images(root=voc_dir, is_train=is_train,  max_num=max_num)
/ V% x, j; T$ P( O" n" d' Z+ z; e2 T# 由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本需要通过自定义的filter函数所移除
$ M; s2 i2 K$ x0 ?$ h% m1 V        self.features = self.filter(features) # PIL image* P. |+ v3 I4 \5 b/ v
        self.labels = self.filter(labels)     # PIL image
8 t8 i# @- Z7 a0 I        self.colormap2label = colormap2label1 \$ O* w( }$ a# T- v% F$ G+ D8 e# x
        print('read ' + str(len(self.features)) + ' valid examples')
. Y! U! i. z) n0 F, e. M
% k4 o$ ]7 s0 ]8 |    def filter(self, imgs):* Z3 z. V% j) W  q6 v9 I
        return [img for img in imgs if (
2 n: k! }6 V) w. |" b2 O& A; `2 x9 N            img.size[1] >= self.crop_size[0] and img.size[0] >= self.crop_size[1])]& S8 T. p' q* C5 F$ h* W- ~0 P
6 i$ o+ `5 C" ?2 ^; H$ Q7 x5 v
    def __getitem__(self, idx):
- t) C4 K6 D0 G; o) S9 G        feature, label = voc_rand_crop(self.features[idx], self.labels[idx], *self.crop_size)
, r2 I+ v5 H/ f5 F& S- U& ~                                # float32 tensor           uint8 tensor (b,h,w)* r8 c1 s+ G! X/ }) v- c5 U
        return (self.tsf(feature), voc_label_indices(label, self.colormap2label))
* `1 K" J/ q5 q1 H# A: E6 j2 @; U. z* w, Q! }
    def __len__(self):$ z0 t6 A$ _9 s- o2 _2 O& e
        return len(self.features)
, f4 q" I: w+ f3.2 读取数据集! _$ Z9 E4 Y* p4 e% i
( X; H* i# f, U4 W* u
通过自定义的VOCSegDataset类来分别创建训练集和测试集的实例。因为待会用的是全卷积网络,所以随机裁剪的输出图像的形状可以自己指定,这里指定为320×480​ 320\times 480​320×480​。
& h4 S) g% T7 z4 k( g- N7 L1 s! H$ i4 D. E1 ~: D2 r0 p
batch_size = 32 # 实际上我的小笔记本不允许我这么做!哭了(大家根据自己电脑内存改吧)
4 X5 v$ u% D+ J8 r4 H6 X! N4 r+ o9 tcrop_size = (320, 480) # 指定随机裁剪的输出图像的形状为(320,480)
3 j5 k& I6 S. {' gmax_num = 20000 # 最多从本地读多少张图片,我指定的这个尺寸过滤完不合适的图像之后也就只有1175张~) E' M. K1 H8 S
9 D% v. }: s2 ~) _- ~
# 创建训练集和测试集的实例
% V1 O# A8 V6 T2 _; Cvoc_train = VOCSegDataset(True, crop_size, voc_dir, colormap2label, max_num)
% v& K3 d8 o! D0 @% e# f( D% zvoc_test = VOCSegDataset(False, crop_size, voc_dir, colormap2label, max_num)
% _9 k4 e4 ^6 I0 e5 }6 |8 ?6 s6 A( {# H1 Y6 S
# 设批量大小为32,分别定义【训练集】和【测试集】的数据迭代器
' B6 n% l7 J# s& @/ u& `; Snum_workers = 0 if sys.platform.startswith('win32') else 4& S( c" D/ \5 a5 x5 }" J; t
train_iter = torch.utils.data.DataLoader(voc_train, batch_size, shuffle=True,# c; E7 V$ \( X1 D3 Z  h0 v; l
                              drop_last=True, num_workers=num_workers)
. Y: x% L, P' L5 vtest_iter = torch.utils.data.DataLoader(voc_test, batch_size, drop_last=True,
: i8 u% U( `9 G5 A/ c3 `2 y$ p  n                             num_workers=num_workers)3 O+ l& M( d; K* c
+ z; f! n/ d, G! X; ~! O$ [
# 方便封装,把训练集和验证集保存在dict里5 h: f& s- {/ S# ~2 |
dataloaders = {'train':train_iter, 'val':test_iter}9 g5 s  a9 ^( [. d" `1 V& a
dataset_sizes = {'train':len(voc_train), 'val':len(voc_test)}
. X, g% j! _/ F  o' S. ]8 m8 H9 M2 b. O
9 {; F/ E2 h& T; T' s4 构造模型4.1 预训练模型

下⾯我们使⽤⼀个基于 ImageNet 数据集预训练的 ResNet-18 模型来抽取图像特征。

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
/ p0 y  R% z1 o/ @8 Q
, F5 i4 d4 I0 J; M2 v' a. p* z/ W( Enum_classes = 21 # 21分类,1个背景,20个物体0 K' n. G* w! V" H4 u4 `1 H' d
model_ft = resnet18(pretrained=True) # 设置True,表明要加载使用训练好的参数
3 A" ~- R0 y+ [& M- {2 h0 l- E3 F' _- U, n
# 特征提取器4 P7 Y- a4 D. A$ J1 C/ E& F0 q6 d2 P
for param in model_ft.parameters():
2 g9 O. t5 W+ G6 {8 k0 r# |    param.requires_grad = False; u1 n9 P8 i. {1 Z
4.2 修改成FCN
$ v6 T$ R/ K4 p7 w0 D0 w
+ \+ _, g# E5 P, f& l0 B全卷积⽹络(顾名思义全部都是卷积层)先使⽤卷积神经⽹络抽取图像特征,然后通过 1×1​ 1\times 1​1×1​ 卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的⾼和宽变换为输⼊图像的尺⼨。模型输出与输⼊图像的⾼和宽相同,并在空间位置上⼀⼀对应:
* P. o) e7 K0 o最终输出的通道包含了该空间位置像素的类别预测。
) z5 q# t& N( m/ L% Y: j" a, y/ Y# l0 g
对于转置卷积层,如果步幅为 S​ S​S​、填充为 S/2​ S/2​S/2​ (假设为整数)、卷积核的⾼和宽为 2S​ 2S​2S​,转置卷积核将输⼊的⾼和宽分别放⼤ S​ S​S​ 倍。% h; L9 }* Z, L- }1 I
* H8 `1 b( J. p. B3 m6 V. M
可以先打印model_ft,可见 ResNet-18 的最后两层分别是全局最⼤池化层GlobalAvgPool2D 和 全连接层。全卷积⽹络不需要使⽤这些层。通过测试,当输入图像的 size 是(batch,3,320,480) (batch,3,320,480)(batch,3,320,480) 时,通过除最后两层的预训练网络后输出的大小是 (batch,512,10,15) (batch,512,10,15)(batch,512,10,15),也就是 feature featurefeature 的宽高比输入缩小了 32 3232 倍,只需要用转置卷积层将其放大 32 3232 倍即可。5 y: V* o/ t, \1 a' w/ V/ q: U
( W) r4 j) Z; J. [. T* u, t. Z% a
model_ft = nn.Sequential(*list(model_ft.children())[:-2], # 去掉最后两层
5 m6 {& E' C( \+ L8 i$ k              nn.Conv2d(512,num_classes,kernel_size=1), # 用大小为1的卷积层改变输出通道为num_class
& Q* X% l8 ~( g  y$ H' }, E4 C, ^, [              nn.ConvTranspose2d(num_classes,num_classes, kernel_size=64, padding=16, stride=32)).to(device) # 转置卷积层使图像变为输入图像的大小
6 @: @5 ]( a, u! t( y& w5 i
  s$ C% t3 V8 J1 w4 d- ^# 对model_ft做一个测试3 U3 o8 o9 v8 I5 D9 ]- `; u& q
x = torch.rand((2,3,320,480), device=device) # 构造随机的输入数据* ]. T: O8 k; Q, s8 `
print(net(x).shape) # 输出依然是 torch.Size([2, 21, 320, 480]) ' Y; K2 S- ?6 l. y4 ~! g
3 e- U8 J& h# m4 i# S& l
# 打印第一个小批量的类型和形状。不同于图像分类和目标识别,这里的标签是一个三维数组
0 p' b9 [" T; d. t7 L+ X7 K# for X, Y in train_iter:
, ~- c! [. {- t, s8 O* I9 J6 t#     print(X.dtype, X.shape)4 a+ f' b2 i0 I  a5 z& T% Y
#     print(Y.dtype, Y.shape)
7 u- D7 p& H; a% M#     break$ d0 v# H5 \% B- P

2 L4 g4 w& A) m" f) O7 z7 Z/ L3 j& U) S9 {- N% p
4.3 初始化转置卷积层0 p6 }2 X" A+ t7 ^7 t/ u- i# M
9 N1 z% |! Q$ Q
在图像处理中,我们有时需要将图像放⼤,即上采样(upsample)。上采样的⽅法有很多,常⽤的有双线性插值。简单来说,为了得到输出图像5 j  O' T9 i! R/ c
在坐标 (x,y) (x, y)(x,y)上的像素,先将该坐标映射到输⼊图像的坐标 (x',y') (x′, y′ )(x′,y′)。例如,根据输⼊与输出的尺⼨之⽐来映射。映射后的 x' x′x′ 和 y' y′y′ 通常是实数。然后,在输⼊图像上找到与坐标 (x',y') (x′, y′ )(x′,y′)最近的 4 44 个像素。最后,输出图像在坐标 (x,y) (x, y)(x,y)上的像素依据输⼊图像上这4 44个像素及其与 (x',y') (x′, y′ )(x′,y′)的相对距离来计算。双线性插值的上采样可以通过由以下bilinear_kernel函数构造的卷积核的转置卷积层来实现。
3 J" Y( v: {) p7 B7 \- d2 T, O
2 {  p/ q; W2 f0 T# ]$ j0 I2 l+ ~: L' X7 n% Q
# 双线性插值的上采样,用来初始化转置卷积层的卷积核
3 f: q+ y: c% ]def bilinear_kernel(in_channels, out_channels, kernel_size):" Y, U! U7 |% O! g' T: G4 s
    factor = (kernel_size+1)//2
- M7 F7 c7 g: c6 u    if kernel_size%2 == 1:
3 ^1 H& M5 p6 J* W) D5 {4 Y5 F4 m        center = factor-1# a& M$ t4 \, Z8 I$ F
    else:+ K% `% x4 z7 Z. i2 c: F1 y
        center = factor-0.5
; V6 ~" C7 k3 H  I; e2 f9 s2 T0 f    og = np.ogrid[:kernel_size, :kernel_size]9 s* x& a. m% W) R
    filt = (1-abs(og[0]-center)/factor) * (1-abs(og[1]-center)/factor)
$ C/ l/ X; [) o! b- a1 j: F    weight = np.zeros((in_channels,out_channels, kernel_size,kernel_size), dtype='float32')
5 C4 C) B" e3 \. N1 w    weight[range(in_channels), range(out_channels), :, :] = filt
7 w1 n* O; p1 @/ r" N5 S9 J3 |    weight = torch.Tensor(weight)
( V  r  S" w3 j3 t    weight.requires_grad = True
& l6 K5 Y* A3 s( p4 w! s5 ~% Q    return weight
( F* e9 n8 I8 W- g& y. g4 ~$ M2 H1 j- Z- {

. q8 ~0 H6 E. u9 L8 T+ l$ |在全卷积⽹络中,将转置卷积层初始化为双线性插值的上采样。对于1×1 1\times 11×1卷积层,采⽤Xavier XavierXavier随机初始化。# Y) o6 E: Y8 U2 d/ o
6 a9 d3 M. p, }3 K& P  |, I8 K0 a
nn.init.xavier_normal_(model_ft[-2].weight.data, gain=1)5 K! X8 U1 x! w) b
model_ft[-1].weight.data = bilinear_kernel(num_classes, num_classes, 64).to(device)
0 x4 G/ F& b; c  v8 Y) I: G3 u0 W! E# a' g
5 {2 V" D( q% U5 g- Z

( I# W- L; e0 s5 训练模型

现在可以开始训练模型了。这⾥的损失函数和准确率计算与图像分类中的并没有本质上的不同。有一个 blog 我认为说的很详细,图也画得很好:https://blog.csdn.net/Fcc_bd_stars/article/details/105158215

9 q) s; _* Y; {; N/ W! _7 X; o
def train_model(model:nn.Module, criterion, optimizer, scheduler, num_epochs=20):
: m0 n  I: D7 [7 Z* c    since = time.time()/ H2 E! U& \  z
    best_model_wts = copy.deepcopy(model.state_dict())
$ T( T: o- U2 `% {    best_acc = 0.0
8 W5 @3 `, _/ Z. J7 r  m    # 每个epoch都有一个训练和验证阶段
& f4 X) \" B$ s" Z  A    for epoch in range(num_epochs):
0 @# G- @8 _3 x, ^! V        print('Epoch {}/{}'.format(epoch, num_epochs-1))
& w9 q2 U5 `0 V3 D# d* T        print('-'*10)
; q/ \3 w3 Z0 S( W% ?" Y        for phase in ['train', 'val']:
7 r$ l, C1 l% x4 u( P, ~, F            if phase == 'train':
+ u$ X* [( ~7 U3 ^$ p. x                scheduler.step()% ]5 ]  N+ T' `) D2 Y& _6 E' d
                model.train()
) ]1 s; C# B1 o7 C; `            else:. I, D) b1 r1 {2 |: {  C
                model.eval()8 {9 d! @8 J3 g9 a* n
            runing_loss = 0.0
& o) F, t& a& c7 Q            runing_corrects = 0.01 J+ O' T8 a2 Z4 D
            # 迭代一个epoch
& q& _9 M& r1 b9 Z' X            for inputs, labels in dataloaders[phase]:
7 d; h: W# o4 A                inputs, labels = inputs.to(device), labels.to(device)" o3 F8 G) u  q! B
                optimizer.zero_grad() # 零参数梯度/ {# a% \! o7 B+ o! ]* C3 M/ |7 f/ ^
                                # 前向,只在训练时跟踪参数
8 O& Y' X/ \# f4 W  K0 y                with torch.set_grad_enabled(phase=='train'):
; ~- f9 g& O8 h1 h7 U1 l7 s                    logits = model(inputs)  # [5, 21, 320, 480]* _' }0 h- H5 T. \( f3 Z
                    loss = criteon(logits, labels.long())" _7 r: d6 G( g. ^- g& F
                    # 后向,只在训练阶段进行优化
, {3 s3 K' u2 e# J- ^                    if phase=='train':
4 ]0 ]4 p7 L' n                        loss.backward()
5 `9 D) u$ M( n3 N                        optimizer.step()2 B& V% M$ \3 J3 d
                                # 统计loss和correct' ]9 X2 x5 C5 R8 n; u+ _. N% ^
                runing_loss += loss.item()*inputs.size(0)
/ u& |5 E. r9 T                runing_corrects += torch.sum((torch.argmax(logits.data,1))==labels.data)/(480*320)3 |8 H! i- d: k( C9 Q

: n& g, U# U0 E            epoch_loss = runing_loss / dataset_sizes[phase]  s. ]9 _! W* t/ `+ u
            epoch_acc = runing_corrects.double() / dataset_sizes[phase]+ k; b+ N5 A& e: y3 Q  x, }( N
            print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))/ w3 U2 I) J) D; Y$ Q( }8 s
                        # 深度复制model参数
- A: _. O& _$ \2 m& V  v' G            if phase=='val' and epoch_acc>best_acc:5 @6 K7 ^5 p9 _: s; b: n% u
                best_acc = epoch_acc4 e- l' S  T3 ?( m8 q
                best_model_wts = copy.deepcopy(model.state_dict())
9 s1 y* X+ g. Y: E        print()
- e/ B' l3 ~) @: l1 C# V3 b0 g    time_elapsed = time.time() - since;; T$ S( i" R, H5 t1 k
    print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed//60, time_elapsed%60))
9 P* V: c7 E% R( f3 ^8 o; q    # 加载最佳模型权重
7 l1 |) R% _# |- \& v    model.load_state_dict(best_model_wts)3 y2 U  W& V  o/ f; C3 l
    return model
7 @* j( b% }' V9 R7 C: k2 p6 d/ G+ O3 f/ r  [
下面定义train_model要用到的参数,开始训练
, `% g* |( G; u. H3 X& J- j7 T3 o
epochs = 5 # 训练5个epoch6 H/ Z( E4 y4 H; y
criteon = nn.CrossEntropyLoss(); U  L" I; H8 N5 r' X! m5 I( G
optimizer = optim.SGD(model_ft.parameters(), lr=0.001, weight_decay=1e-4, momentum=0.9)( h/ F% m3 g! ~
# 每3个epochs衰减LR通过设置gamma=0.1
! A( h) }3 F: ~9 |' oexp_lr_scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)& S- p$ L5 }1 {# Q+ [  e
0 E; R( O0 Q& t. T
# 开始训练
4 p5 K# V4 T: B! Wmodel_ft = train_model(model_ft, criteon, optimizer, exp_lr_scheduler, num_epochs=epochs)
) [/ J) |4 D* h, n2 l$ o
1 a" U1 Q2 C+ |% X: s* q- A6 测试模型

为了可视化每个像素的预测类别,我们将预测类别映射回它们在数据集中的标注颜⾊。

def label2image(pred):
  l$ O( C9 c! J$ X9 G) H! E7 n( `    # pred: [320,480]  E! \5 ^/ V+ L: l; r
    colormap = torch.tensor(VOC_COLORMAP,device=device,dtype=int)0 G2 S5 w9 G- x0 v8 D
    x = pred.long()% S/ O+ S1 @" U6 l+ U' J4 Z1 M
    return (colormap[x,:]).data.cpu().numpy()
* O$ l. n1 D6 X5 O$ D
) s% B# z* b' [# {7 v

下面这里提供了两种测试形式

6.1 通用型

其实如果要用于测试其它数据集,也是要改动一下的 : ) 😃

mean=torch.tensor([0.485, 0.456, 0.406]).reshape(3,1,1).to(device)
% f! |1 t: J) ^$ Y/ b" R# ostd=torch.tensor([0.229, 0.224, 0.225]).reshape(3,1,1).to(device)
: z9 U8 u9 ^* T! Hdef visualize_model(model:nn.Module, num_images=4):
' W  s% o  M& H! b" p    was_training = model.training
3 h. G0 f& R& t    model.eval()
& Y0 P$ [9 P, `+ A    images_so_far = 0
" e# v# `7 @5 t8 ?5 a    n, imgs = num_images, []9 N8 e- y/ b! ?9 F
    with torch.no_grad():+ W" {; a3 }0 M# X
        for i, (inputs, labels) in enumerate(dataloaders['val']):: |: L2 ]. @$ I
            inputs, labels = inputs.to(device), labels.to(device) # [b,3,320,480]
4 t( @! |4 w0 _* D0 C            outputs = model(inputs)
: h" g2 ^; P# F7 d1 X            pred = torch.argmax(outputs, dim=1) # [b,320,480]
2 ~5 o7 p3 f1 R            inputs_nd = (inputs*std+mean).permute(0,2,3,1)*255 # 记得要变回去哦% t9 O" F& F  j& o4 `- A
( v- M+ B+ Q4 y# B0 {# N: f4 l2 A
            for j in range(num_images):
+ V6 |+ h$ s8 f$ ^$ u2 r( X                images_so_far += 11 H3 _% B1 z0 @/ }7 Y
                pred1 = label2image(pred[j]) # numpy.ndarray (320, 480, 3)
  V3 V8 N" c7 I0 r$ p: x* d                imgs += [inputs_nd[j].data.int().cpu().numpy(), pred1, label2image(labels[j])]
. J- b. Q( n' {' }                if images_so_far == num_images:/ @+ _! s- M8 B2 O7 s
                    model.train(mode=was_training). t1 Z) x! L/ I% a# P) }7 [
                    # 我已经固定了每次只显示4张图了,大家可以自己修改
' T6 c- F' M1 L0 O                    show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n): b  u7 a' J1 Z
                    return model.train(mode=was_training)
1 b6 o# v3 _+ N
8 E9 `0 h! V7 @9 ~) ?5 \* `1 `# 开始验证! S. V+ L7 n$ @4 y9 a& b* ]& U
visualize_model(model_ft)
# j4 F7 w, \5 |7 Q6.2 不通用

在预测时,我们需要将输⼊图像在各个通道做标准化,并转成卷积神经⽹络所需要的四维输⼊格式。

# 预测前将图像标准化,并转换成(b,c,h,w)的tensor
; I/ i7 w" B4 g4 c2 A6 u# Q( Rdef predict(img, model):# q' b1 x& S4 \4 J0 _  U
    tsf = transforms.Compose([* S' y. x# h7 W
            transforms.ToTensor(), # 好像会自动转换channel
' M. B0 C- I! o5 r2 f" R3 o            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])])8 c- `  x8 Y( o% u" a6 d# u
    x = tsf(img).unsqueeze(0).to(device) # (3,320,480) -> (1,3,320,480)3 t$ ]. w* k2 r% n/ i5 C
    pred = torch.argmax(model(x), dim=1) # 每个通道选择概率最大的那个像素点 -> (1,320,480)
0 w4 c7 q3 b0 p. b) z1 v1 p$ Q# k    return pred.reshape(pred.shape[1],pred.shape[2]) # reshape成(320,480)
5 K4 S3 C$ N/ ^5 X, i3 A3 o7 ?8 O) ^  \
* h5 f! j# c$ a- N7 @- cdef evaluate(model:nn.Module):
& N: Y6 }; Q2 D5 s! j    model.eval(); `+ @& O3 `2 R+ O) V8 P: ]5 v  p
    test_images, test_labels = read_voc_images(voc_dir, is_train=False, max_num=10) / F! _: G6 b* }0 c5 n5 \$ a& g
    n, imgs = 4, []7 m: w6 m* B! j1 d
    for i in range(n):
6 U+ o7 ?; f( C) g. X$ K        xi, yi = voc_rand_crop(test_images, test_labels, 320, 480) # Image
! k5 k. z  G- ]: n: ]7 `0 R9 d% G  x) P: ^        pred = label2image(predict(xi, model))
9 o6 S, h2 d+ w: n  S" k. a        imgs += [xi, pred, yi]* ], |$ J/ f7 `4 T
    show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n)
, `. g) g/ U- Z. ]" b. Q
. n/ ]& w2 m+ p, ^# 开始测试
5 \; j+ H# G0 Revaluate(model_ft)1 ^$ f8 ?0 Q1 ~: {3 I

& b8 m+ `* W4 g0 E# n! q7 结语

我只训练了3个epoch,下面是训练输出


) M6 E, n  P1 `6 CEpoch 0/2
# E7 q1 p$ ~+ o8 G" x( H$ T8 w. B0 \----------: B, u0 A, x* s; K  s/ c' @
train Loss: 1.7844 Acc: 0.58357 L$ x4 T# {2 j, k3 E7 m
val Loss: 1.1669 Acc: 0.6456. Z5 X4 L2 O. H  d

: H9 U# u! r5 m0 _- `: G! pEpoch 1/2, D7 C. o! w, v, t; L; s
----------' Q& A" b5 |  l. B/ D
train Loss: 1.1288 Acc: 0.6535- s# l; m) n0 E1 [! Y
val Loss: 0.9012 Acc: 0.6929# [; g6 R9 {8 B- w7 B9 l, I
' X5 ^# _) c: w& O4 y
Epoch 2/27 L: D, N! a4 E* ?( E
----------3 n, `" n- ~9 \5 d7 v9 F
train Loss: 0.9578 Acc: 0.67063 H5 Y2 r* u# v% E& X" E5 g. U
val Loss: 0.8088 Acc: 0.6948
+ w! Q; _2 H# b: ^5 ^/ T7 O5 _4 i5 [+ A  g8 k/ ~" t) w
Training complete in 6m 37s- e  E% P6 h  c; N

2 z" X7 {" K. C* J, t
% G0 a0 N" h8 V) W. {9 j4 E 2.jpg
4 @& q4 [$ P6 Z! G9 H
6 `% _! O; t3 y% J" G1 W当 epochs = 5 时,训练集的精度在 89 8989% 左右,测试集的精度可以达到 86​ 86​86​ %。5 A! Q/ ?+ h- h8 t3 n/ T8 y% J  y

; G* }  g+ W  D$ J对于这个模型用 ResNet-50 作特征提取器会有更好的效果,不过训练的时间也会更长。还有超参数lr, weight_decay, momentum, step_size, gamma 以及1×1 1×11×1卷积层和转置卷积层的初始化方式也可以继续调。' R$ l! w, {  J

5 S& b  L) D; p3 e) D( ^: W3 j+ s' A6 V
: ?& r! w: b7 I1 j! a% \语义分割还有很多可用的模型,本文用的是 FCN,在其它一些模型上会有更好的表现:
+ B. t3 U  @% m* o% ?
* x6 f) V2 C$ }& D& ~  N9 D# WDeeplab V3+ 具有可分离卷积的编码器/解码器,用于语义图像分割[论文]7 L: d+ ]/ F6 L$ ~4 V" s
GCN 通过全局卷积网络改进语义分割[论文]
; g; p: |" {! l) N4 l# oUperNet 统一感知解析( B5 i# p4 g! _" d* ^5 ]
ENet 用于实时语义分割的深度神经网络体系结构[论文]
1 ?( b6 ~& C* e4 y7 q5 eU-Net 用于生物医学图像分割的卷积网络8 _) w! @( Y( Y0 [" K  e
SegNet 用于图像分段的深度卷积编码器-解码器架构。. R8 C6 e; ?- X
还有(DUC,HDC)、PSPNet等。
! q5 q+ y" B& q% w/ A, w8 [( J& R' t1 l; E
常用的语义分割数据集也有很多:Pascal VOC、CityScapes、ADE20K、COCO Stuff等。
* x  T; ^6 i/ ~) U$ H8 ~+ Q) K. y5 N% t6 B! p
对于损失函数,除了交叉熵误差,也可以用这些:
. v  X4 B+ `$ K" M* ]8 w7 G0 @% X3 Z" O8 {% h+ ~3 x
Dice-Loss 可以测试两个样本之间的重叠度量,可以更好地反映训练目标,但该损失函数具有很强的非凸性,很难优化。
( F% W. N! h  N, l# ?* _4 zCE Dice loss Dice 损失与 CE 的总和,CE 提供了平滑的优化,而 Dice 损失则很好地表明了分割结果的质量。5 b$ N- ^5 I2 x3 m. }, Q
Focal Loss CE 的另一种版本,用于避免类别不平衡而降低了置信度的情况。
2 D- L/ k5 v: M1 D/ b7 {' lLovasz Softmax 查看论文:Lovasz - softmax损失。: [+ _) O+ j% A- b' N
7 p! `( R; |3 \/ u2 Y% R

; N0 Y' J% O) w3 R4 X2 g% X1 S7 D
; I/ O8 X8 j: g( A3 N+ X
8 I4 \3 Z; Q; i) F8 [0 ]2 J  B, z5 w, X' o. c
————————————————& g; Q( @, ]$ F$ f' w4 s
版权声明:本文为CSDN博主「小红不吃糖」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。2 I! ^7 c0 v0 k5 J& {
原文链接:https://blog.csdn.net/qq_43280818/article/details/105916507
! B8 B8 E6 z9 C* _* V& z
7 ?, Y9 J: Y% ~- Z. _
1 Z/ s" B0 _: ~- Y! Y7 `  B
  X* L' w) {( M+ j( p% H




欢迎光临 数学建模社区-数学中国 (http://www.madio.net/) Powered by Discuz! X2.5