数学建模社区-数学中国

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

作者: 杨利霞    时间: 2020-5-4 15:03
标题: Pytorch实战语义分割(VOC2012)
* z) _3 A( @* x1 _! ^: r3 b( W/ c" p% g
0 @7 l' J7 _: H( j5 j; v+ M  e3 r
Pytorch实战语义分割(VOC2012)' u) G) L( g) K* O4 R
本文参照了《动手深度学习》的9.9、9.10章节,原书使用的是 mxnet 框架,本文改成了pytorch代码。
  d3 W1 \6 u: R/ d$ h4 g语义分割(semantic segmentation)问题,它关注如何将图像分割成属于不同语义类别的区域。值得一提的是,这些语义区域的标注和预测都是像素级的。' s0 `2 P+ F! ~9 e& u) m

# d! l: I8 z( [" \) _2 s0 Y, c) r3 M! c
语义分割中图像有关狗、猫和背景的标签
4 m: i( ?5 j7 ^; {/ {% g: p8 f文章目录+ A/ _1 y! h) o5 D) }  c8 @. I
7 t) `5 u4 F* Q2 k7 d8 n- x
1 图像分割和实例分割
2 U6 D2 e! x( d- c% l) N0 e! t3 b2 Pascal VOC2012语义分割数据集
, q1 o* C& _+ T' F, y2.1 导入模块
! ~+ A$ @8 v5 ~7 m2.2 下载数据集# t' c' r5 l- o, i' A
2.3 可视化数据
  ^2 f/ }& N# K1 V+ ^; R2.4 预处理数据# W4 u, t; ^( v- M1 N4 T
3 自定义数据集类
1 C# K/ ]" s+ J+ P3 M8 W3.1 数据集类+ @( _8 R9 \. L( o' B# S- L+ k
3.2 读取数据集. G1 {1 s$ e. M/ `
4 构造模型
; `: A* z, J' [* K! @! u, L4.1 预训练模型
6 @* E0 _( j  N& _4.2 修改成FCN# E* m* D9 t7 F( G8 W
4.3 初始化转置卷积层
: Q9 b7 e5 J2 e7 @) f8 k: w5 训练模型7 k% t) e. n7 E! A. S7 Z# o
6 测试模型. Z4 Z, j5 w# z$ |, m+ K9 ~' e
6.1 通用型7 B. }) l6 p: ?$ r7 x/ R
6.2 不通用/ _8 z: L$ N  R, ]3 i
7 结语
4 B  a3 [* t' w5 T$ Y1 B2 R1 图像分割和实例分割+ K8 r, M1 m: P6 v; _8 M9 V$ r

3 D" U8 {8 t2 i: V: z计算机视觉领域还有2个与语义分割相似的重要问题,即图像分割(image segmentation)和实例分割(instance segmentation):
8 D( B! w/ E$ J: D- e
  i0 Z3 {1 [: Q- }( {图像分割将图像分割成若干组成区域。这类问题的方法通常利用图像中像素之间的相关性。它在训练时不需要有关图像像素的标签信息,在预测时也无法保证分割出的区域具有我们希望得到的语义。以上图的图像为输入,图像分割可能将狗分割成两个区域:一个覆盖以黑色为主的嘴巴和眼睛,而另一个覆盖以黄色为主的其余部分身体。
: D: M( h- [% f2 z" S实例分割又叫同时检测并分割(simultaneous detection and segmentation)。它研究如何识别图像中各个目标实例的像素级区域。与语义分割有所不同,实例分割不仅需要区分语义,还要区分不同的目标实例。如果图像中有两只狗,实例分割需要区分像素属于这两只狗中的哪一只。) s, y* P9 G! [1 Y5 X
2 X7 ?  w3 q& @$ R
2 Pascal VOC2012语义分割数据集" Q. ^$ S# ?$ j8 f0 _
( X8 e  P1 Y3 M" V5 u) F% k) T
2.1 导入模块! t  i8 M0 h% Z# q
import time
+ U" I% U- w- Bimport copy  R5 b$ t1 p  @/ q
import torch: b% E0 Q0 Y, I) `$ g0 t5 v
from torch import optim, nn
( H  v7 C7 _8 Aimport torch.nn.functional as F0 P) ~: R& E) y* Z4 ^# j
import torchvision/ S% x  t! L8 \
from torchvision import transforms
: d& F( O1 E- _6 Hfrom torchvision.models import resnet18$ A/ b4 Y& a2 K  h
import numpy as np
* U+ ], M+ G" R. l* k, }* `& P; Ffrom matplotlib import pyplot as plt
" S) G8 L7 W$ S' a9 E5 efrom PIL import Image/ U  x/ f! n/ E+ ?( f8 V
import sys
: x, ^3 O6 c2 j1 z, h4 j9 u; wsys.path.append("..")
* l4 I6 i/ j( G0 K6 y7 afrom IPython import display( G0 i4 k" K- |; q1 L. Y2 S1 a
from tqdm import tqdm( t+ z0 b4 S2 J5 B
import warnings4 f' W( j5 v$ q3 m. |6 G
warnings.filterwarnings("ignore")
% Z" B3 V# r# N$ i4 a& q
1 B0 @& G# @: q3 n: l2.2 下载数据集4 j- ]  r+ X) `, \- \

/ H% f! @; ?5 \) y6 ^语义分割的一个重要数据集叫作Pascal VOC2012,点击下载这个数据集的压缩包,大小是2 GB左右,所以下载需要一定时间。下载后解压得到VOCdevkit/VOC2012文件夹,然后将其放置在data文件夹下,VOC2012文件目录是这样的:
  Y8 B0 t/ d+ l+ H- t
1 w- N- ]& v" \  j
& Z7 }. s& z1 C1 x6 F" E& Q( eImageSets/Segmentation路径包含了指定训练和测试样本的文本文件
  q0 d' B7 Y8 m6 Y" BJPEGImages和SegmentationClass路径下分别包含了样本的输入图像和标签。这里的标签也是图像格式,其尺寸和它所标注的输入图像的尺寸相同。标签中颜色相同的像素属于同一个语义类别。
7 C# j8 y/ u6 _' Z% [0 w6 k2.3 可视化数据0 \( w; B7 `5 ^8 t6 W
1 G5 n2 N8 m! B2 Q1 a
定义read_voc_images函数将输入图像和标签读进内存。& U. @* I# U$ F: M$ Z

& N4 {6 S6 z- e! N2 s3 B# Hdef read_voc_images(root="../../data/VOCdevkit/VOC2012", is_train=True, max_num=None):
2 r. I: {7 g6 a! b9 e$ ?- A    txt_fname = '%s/ImageSets/Segmentation/%s' % (root, 'train.txt' if is_train else 'val.txt')( J# n# ^- Z3 p; {: Z
    with open(txt_fname, 'r') as f:
$ P: k% `- d1 ]        images = f.read().split() # 拆分成一个个名字组成list
; [* U! {, O1 h& B! M8 x    if max_num is not None:
5 p( G. \- O3 a/ f+ J: C' U        images = images[:min(max_num, len(images))], B& D" [2 U& r4 _
    features, labels = [None] * len(images), [None] * len(images)( G8 C! U( P8 h5 s% ?9 e
    for i, fname in tqdm(enumerate(images)):
# n$ A" q8 G8 M( ]: V8 T. D6 h        # 读入数据并且转为RGB的 PIL image
- X9 B# r0 m0 m1 ]$ k        features = Image.open('%s/JPEGImages/%s.jpg' % (root, fname)).convert("RGB")0 o# e! {8 m% G8 P
        labels = Image.open('%s/SegmentationClass/%s.png' % (root, fname)).convert("RGB")
. V; h/ k# L: H9 f# F4 h, e    return features, labels # PIL image 0-255. T" H, _5 m! u9 [

  m% G5 C" X, l, f& f2 U6 W: H定义可视化数据集的函数show_images
2 O1 o% U2 w' Q5 d3 j" |& W9 }$ _! R
5 l3 Y* B7 W' i* H+ `- q- t0 e# 这个函数可以不需要9 r4 U' R* I, K
def set_figsize(figsize=(3.5, 2.5)):
% a8 [9 Y' M  i, d' e! I    """在jupyter使用svg显示"""
6 s$ i( V& H) A; O    display.set_matplotlib_formats('svg'), B3 E6 J% `9 _2 s8 [1 r2 W; k& K
    # 设置图的尺寸6 L6 T& m5 t2 f3 F
    plt.rcParams['figure.figsize'] = figsize
1 k8 G9 O! q% ~2 i2 H3 H5 i$ n5 U2 n! Q4 F
def show_images(imgs, num_rows, num_cols, scale=2):: M. `3 O1 Q9 g& @+ K
    # a_img = np.asarray(imgs)
' u4 E$ ?+ J8 A" ]3 \    figsize = (num_cols * scale, num_rows * scale)% b9 ]$ z) `3 Y- q1 K
    _, axes = plt.subplots(num_rows, num_cols, figsize=figsize); z3 `+ ^3 W1 l9 s
    for i in range(num_rows):- n& V3 Z' m* i1 J' U% ~
        for j in range(num_cols):- u+ K, X& a" Z
            axes[j].imshow(imgs[i * num_cols + j])8 ^9 X; K2 c% L- m/ j
            axes[j].axes.get_xaxis().set_visible(False)1 \" u% e/ l8 c' I: T( h: k
            axes[j].axes.get_yaxis().set_visible(False)- S: @0 c- Q  s
    plt.show()2 G3 W/ S6 @- f) E! k; Z6 A; v) u
    return axes
/ U& F+ I) d4 h: R" Y; n8 ?5 W. q! V- Q" V" _8 f" M0 f
定义可视化数据集的函数show_images) F. e8 I1 q7 n: I# u9 u% ?5 Z% A

) Q/ @/ _+ h2 T5 q# 这个函数可以不需要; ]6 |5 `. n7 c8 h. \( Q5 y- R
def set_figsize(figsize=(3.5, 2.5)):
/ E% S" A5 b3 t/ K$ o4 R' s1 b    """在jupyter使用svg显示"""4 a) W; n: n: j4 C3 e0 H
    display.set_matplotlib_formats('svg')
' W; F9 T. z, }  ~    # 设置图的尺寸+ w9 u  u* h' A: V: x6 v7 x
    plt.rcParams['figure.figsize'] = figsize
8 Q% J: L. z& [3 c& X& K, E4 b6 l
def show_images(imgs, num_rows, num_cols, scale=2):
2 ]; D. e# P8 e1 T. V( [    # a_img = np.asarray(imgs)
( [$ Q4 P! w( c4 A4 H    figsize = (num_cols * scale, num_rows * scale): N# I/ v* Y0 Y% h1 \* q6 j1 X
    _, axes = plt.subplots(num_rows, num_cols, figsize=figsize)
# T" a. M. ]7 M+ n- \! U3 A  H    for i in range(num_rows):+ C' C5 Q+ D9 T
        for j in range(num_cols):
6 f8 L  z! J+ J) l            axes[j].imshow(imgs[i * num_cols + j])
( M- g) O7 A8 }7 V, l1 j            axes[j].axes.get_xaxis().set_visible(False)5 ]# d4 _$ W; f# `
            axes[j].axes.get_yaxis().set_visible(False)5 n) w! C/ ?, @6 @( ~; s
    plt.show()
! @: y% `3 r# ^4 X4 W8 X    return axes7 y9 O6 m4 S+ w8 l
画出前5张输入图像和它们的标签。在标签图像中,白色和黑色分别代表边框和背景,而其他不同的颜色则对应不同的类别。/ m$ i6 v  {3 }  V

  P) ?# V0 c8 o# R5 s/ i, g# 根据自己存放数据集的路径修改voc_dir
" t  `/ j" Q4 _voc_dir = r"[local]\VOCdevkit\VOC2012"
3 Z6 i+ G0 ?- E" E' A8 {: C7 Ttrain_features, train_labels = read_voc_images(voc_dir, max_num=10)
4 X% D3 w+ O% c' ?  x1 M, on = 5 # 展示几张图像
: j0 ]8 ?, x' E7 B8 N% i9 j2 kimgs = train_features[0:n] + train_labels[0:n] # PIL image
' Z# {8 R  V) L* \2 y0 qshow_images(imgs, 2, n)
* d  Z9 E: G# L+ F! M
' [* ^" u4 x3 t  `  U 1.png - e. A! k3 U$ C5 w4 Q
$ Q1 Y7 V1 Q/ z0 @5 S, v# a& @( n, E
列出标签中每个RGB颜色的值及其标注的类别。
8 h6 e% a* E0 o; b1 @$ S8 _/ N/ X# 标签中每个RGB颜色的值
; h5 y9 _2 O1 tVOC_COLORMAP = [[0, 0, 0], [128, 0, 0], [0, 128, 0], [128, 128, 0],0 ~: V! P2 T+ l# I4 j
                [0, 0, 128], [128, 0, 128], [0, 128, 128], [128, 128, 128],
) ]8 X4 i' k1 g7 \7 H                [64, 0, 0], [192, 0, 0], [64, 128, 0], [192, 128, 0],
& Y: b# m' w0 {: w- f& ]! i& j                [64, 0, 128], [192, 0, 128], [64, 128, 128], [192, 128, 128],
9 ~, z' Q) Q5 }* _) o' x9 E+ O                [0, 64, 0], [128, 64, 0], [0, 192, 0], [128, 192, 0],
' b, G9 A8 F8 V: ?; y3 G2 n8 J0 V                [0, 64, 128]]$ t2 W; l7 P7 l7 O: J
# 标签其标注的类别
! a1 n; `5 z! W% E- ^: `VOC_CLASSES = ['background', 'aeroplane', 'bicycle', 'bird', 'boat',2 Y9 E+ r' c. _! T' H# s7 _
               'bottle', 'bus', 'car', 'cat', 'chair', 'cow',2 o% H9 X) @8 k; s5 i4 v8 E2 ]
               'diningtable', 'dog', 'horse', 'motorbike', 'person',( _% z# w, E8 m% u4 p% G2 a: M" d) M
               'potted plant', 'sheep', 'sofa', 'train', 'tv/monitor']
5 U$ D: {, Z! b) ~9 @! y: q! C有了上面定义的两个常量以后,我们可以很容易地查找标签中每个像素的类别索引voc_label_indices是根据colormap2label把标签里的 rgb 颜色对应上面的VOC_COLORMAP中的下标给取出来,当作 label 。7 O9 |8 @2 j; w# W

  H. W1 W8 D" l有了上面定义的两个常量以后,我们可以很容易地查找标签中每个像素的类别索引voc_label_indices是根据colormap2label把标签里的 rgb 颜色对应上面的VOC_COLORMAP中的下标给取出来,当作 label 。
3 T6 u4 S7 y" L( L% n. N) w3 vcolormap2label = torch.zeros(256**3, dtype=torch.uint8) # torch.Size([16777216])
8 ^4 F4 r5 ?( K- F# J, c2 @for i, colormap in enumerate(VOC_COLORMAP):, P( P; a- ~- v
    # 每个通道的进制是256,这样可以保证每个 rgb 对应一个下标 i5 x3 I+ Y' I+ l% W$ V8 G
    colormap2label[(colormap[0] * 256 + colormap[1]) * 256 + colormap[2]] = i- W% A, k/ {- ]

# ]/ p0 f, ^  M# [# 构造标签矩阵
  r6 l5 X: j# m8 s! h# n3 Hdef voc_label_indices(colormap, colormap2label):& G5 I; _. O8 F2 W4 i
    colormap = np.array(colormap.convert("RGB")).astype('int32')- L; y% a. w" [1 P
    idx = ((colormap[:, :, 0] * 256 + colormap[:, :, 1]) * 256 + colormap[:, :, 2]) 0 I6 x7 e: F# N/ d
    return colormap2label[idx] # colormap 映射 到colormaplabel中计算的下标
! a" _0 J% t8 T3 M/ M8 W
5 }7 t7 }  c0 k1 j可以打印一下结果& e* G7 ^( ?6 T4 B

! E0 y' D" w9 My = voc_label_indices(train_labels[0], colormap2label)( i; C, b& _+ n, q) h7 B3 R
print(y[100:110, 130:140]) #打印结果是一个int型tensor,tensor中的每个元素i表示该像素的类别是VOC_CLASSES
4 A) t$ e8 F0 s6 {" a; G7 L( s0 K& K8 x3 _  V
2.4 预处理数据: Y( U$ l- D$ [8 q

$ y( c) p5 T& O) h. ]0 l5 x8 u在语义分割里,如果使用缩放图像使其符合模型的输入形状的话,需要将预测的像素类别重新映射回原始尺寸的输入图像,这样的映射难以做到精确,尤其是在不同语义的分割区域。所以选择将图像裁剪成固定尺寸而不是缩放。具体来说,我们使用图像增广里的随机裁剪,并对输入图像和标签裁剪相同区域。: y0 ?8 M2 V& H  m- A
) Q  v/ m) `& X

8 ^4 a0 e% W/ gdef voc_rand_crop(feature, label, height, width):
0 v4 x: {; Z. o; H. A2 R    """9 x5 T5 _; t5 T0 l* }( d
    随机裁剪feature(PIL image) 和 label(PIL image).
& ^3 V& C' p! N. a8 m4 q2 h    为了使裁剪的区域相同,不能直接使用RandomCrop,而要像下面这样做  s9 ]  c' |7 C
    Get parameters for ``crop`` for a random crop.3 G2 ^9 B( l- T
    Args:9 m0 @& N/ B! |  s, H2 B' f" }
        img (PIL Image): Image to be cropped.2 g9 ~' [" z; f+ o; r: V
        output_size (tuple): Expected output size of the crop.: P6 ^) g9 [& \0 r
    Returns:( G6 D( j+ a/ b/ ?2 C1 B
        tuple: params (i, j, h, w) to be passed to ``crop`` for random crop.# P9 ?6 O4 ?$ Z8 b. F
    """
3 n. Q6 ^$ C. R7 m3 Q8 b+ m    i,j,h,w = torchvision.transforms.RandomCrop.get_params(feature, output_size=(height, width))4 n1 W: w2 I2 Z9 Y5 X
    feature = torchvision.transforms.functional.crop(feature, i, j, h, w)
7 [/ I# w" y: o% m* o9 i    label = torchvision.transforms.functional.crop(label, i, j, h, w)
2 [/ ^' ?$ d, T  g    return feature, label
3 [/ K1 j; E3 n& |: @! x! q; k( |9 C" g7 ]
# 显示n张随机裁剪的图像和标签,前面的n是5
# l+ E! r9 M8 ^/ q8 h+ Ximgs = []' d9 G2 `/ Q, h3 l% F8 h. m/ a
for _ in range(n):7 ]+ \2 q- g& x3 ]5 p
    imgs += voc_rand_crop(train_features[0], train_labels[0], 200, 300)
% Z! E9 P$ Q6 f/ G' H5 U2 {* _show_images(imgs[::2] + imgs[1::2], 2, n);+ u4 z0 L* I9 _; B
$ S8 _, \* H% U' _2 ^# {  |( H
/ F' K' A9 ?3 S1 V6 C
2.png
- M7 t; L- i* I5 k) U9 [! h, S8 L$ }
5 l8 P9 U# l  Y1 K. X

4 m, C. ?9 I% I: G3 自定义数据集类3 S3 m: ^$ u% x+ P( ~  D: e# V
0 H, A6 |: ]" A- u: _( I
3.1 数据集类5 `: N2 G1 c6 y% q: B+ `! g  h

+ b; o1 @! C6 I7 [6 t" _$ ~: ytorch.utils.data.Dataset是表示数据集的抽象类,因此自定义数据集应继承Dataset并覆盖以下方法
% o$ E4 K0 t- _# r+ Y; V
* g3 Q. J" A/ n) O__len__ 实现 len(dataset) 返还数据集的尺寸。
- R& o1 l( x; x7 J/ f__getitem__用来获取一些索引数据,例如 dataset[idx] 中的(idx)。4 `; J9 ?9 K& M! ^, n5 O
由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本需要通过自定义的filter函数所移除。此外,因为之后会用到预训练模型来做特征提取器,所以我们还对输入图像的 RGB 三个通道的值分别做标准化。
9 R5 ^" T! a+ u* d; p, z: r
, F- I7 b4 }) \class VOCSegDataset(torch.utils.data.Dataset):/ X7 U) O% I& D+ W" t
    def __init__(self, is_train, crop_size, voc_dir, colormap2label, max_num=None):$ `8 E* G; V! U5 O9 l# k+ `9 \
        """
0 o; }1 k; H4 A0 T0 o$ T        crop_size: (h, w), c# F- p7 e  h! x
        """
( D+ R4 Z, M6 C( g        # 对输入图像的RGB三个通道的值分别做标准化
3 t4 O+ x* c; u% w1 L5 y2 P        self.rgb_mean = np.array([0.485, 0.456, 0.406])
* S) o. ^, q& N6 g2 g7 K6 A! b        self.rgb_std = np.array([0.229, 0.224, 0.225])
' n/ Z: S0 V$ O( n1 n2 B) C4 @: V0 y% v; c        self.tsf = torchvision.transforms.Compose([, e0 q' L& X; K/ L
            torchvision.transforms.ToTensor(),
* p) a- b9 k( r            torchvision.transforms.Normalize(mean=self.rgb_mean, std=self.rgb_std)])! j! i) Z4 |' R0 a2 E- V7 a6 b
        self.crop_size = crop_size # (h, w)
, O3 d' B9 J% q# M" p4 p+ E        features, labels = read_voc_images(root=voc_dir, is_train=is_train,  max_num=max_num)
7 `, D: R# u1 S1 z# 由于数据集中有些图像的尺寸可能小于随机裁剪所指定的输出尺寸,这些样本需要通过自定义的filter函数所移除
6 j  L) f* \1 K+ c: }/ c0 {# N        self.features = self.filter(features) # PIL image
( ?- \8 z  R9 g7 U- W        self.labels = self.filter(labels)     # PIL image6 h" Q3 S, @2 U- w9 b
        self.colormap2label = colormap2label
2 {& {% Z3 G$ s1 {% r% }, y        print('read ' + str(len(self.features)) + ' valid examples')
$ c" J& d8 I# x: P
* H6 o/ |& B' n/ u+ ]2 @    def filter(self, imgs):. Y$ J" M) y' k: p
        return [img for img in imgs if (" K4 k' V+ ^5 W- p9 S0 B
            img.size[1] >= self.crop_size[0] and img.size[0] >= self.crop_size[1])]
, O, U# |' [1 P4 r# J
; a+ H/ ?+ c  m2 Y' u2 i    def __getitem__(self, idx):
) H: L9 T6 R: y; o/ V0 t        feature, label = voc_rand_crop(self.features[idx], self.labels[idx], *self.crop_size)
' ?6 }0 a3 f  ]! \6 E7 [: U                                # float32 tensor           uint8 tensor (b,h,w)3 q, d+ s' a5 `2 C! b
        return (self.tsf(feature), voc_label_indices(label, self.colormap2label))' `$ \0 q( {# ~/ G" z% N

- g, B3 J: j  G4 w    def __len__(self):
' b# m- m  ^6 Q* a  ^4 `        return len(self.features)9 Y1 d; s7 ^4 d( M* V
3.2 读取数据集
+ ^# ~, j0 P4 |  T$ q  V/ M/ t. I+ `4 i6 O
通过自定义的VOCSegDataset类来分别创建训练集和测试集的实例。因为待会用的是全卷积网络,所以随机裁剪的输出图像的形状可以自己指定,这里指定为320×480​ 320\times 480​320×480​。3 N8 m; W: Z0 Z3 A5 T6 O
" x" }) w  n( Y% m# s3 x5 f" Z
batch_size = 32 # 实际上我的小笔记本不允许我这么做!哭了(大家根据自己电脑内存改吧)0 K: Y7 N, i. _. n9 E
crop_size = (320, 480) # 指定随机裁剪的输出图像的形状为(320,480)
4 r1 P3 e7 w. e2 I$ c; bmax_num = 20000 # 最多从本地读多少张图片,我指定的这个尺寸过滤完不合适的图像之后也就只有1175张~9 m( M! p; t4 a. `) A$ h
3 k% W1 a# \2 E6 B
# 创建训练集和测试集的实例9 d- l# F/ {* K6 l' B' q  Q
voc_train = VOCSegDataset(True, crop_size, voc_dir, colormap2label, max_num)
: L, `0 s4 ^- W+ I9 S) avoc_test = VOCSegDataset(False, crop_size, voc_dir, colormap2label, max_num)  {/ f: Y6 \3 U# W( ~; W

( R/ X7 R" P+ ]4 j* q# 设批量大小为32,分别定义【训练集】和【测试集】的数据迭代器9 A- }! U5 P0 E0 ^0 x7 Y& X
num_workers = 0 if sys.platform.startswith('win32') else 4
! L2 d- h' i/ k! k" k3 qtrain_iter = torch.utils.data.DataLoader(voc_train, batch_size, shuffle=True,, K2 C. E! I$ a+ L2 V& H( L  _
                              drop_last=True, num_workers=num_workers)) Y1 Y$ r3 b; Q" Q
test_iter = torch.utils.data.DataLoader(voc_test, batch_size, drop_last=True,9 c  {7 M" N+ Q- _2 V" v
                             num_workers=num_workers)
; @- ?/ U$ C/ e$ W( H) t' {$ ~; l4 z: W5 |$ {# M. {
# 方便封装,把训练集和验证集保存在dict里
' j4 G( C/ Z5 Fdataloaders = {'train':train_iter, 'val':test_iter}
: y+ A6 w4 t" V7 @) E6 bdataset_sizes = {'train':len(voc_train), 'val':len(voc_test)}
6 q( b$ d3 ]2 `
, A5 w, T7 ~. G1 v" E! i$ z4 构造模型4.1 预训练模型

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

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')1 [2 G) a' A( b6 N% E
4 ?) v! D; Y; m/ J( ]
num_classes = 21 # 21分类,1个背景,20个物体' @. j! F8 A& H9 @+ \- s9 ]
model_ft = resnet18(pretrained=True) # 设置True,表明要加载使用训练好的参数5 i. ]2 d# X" B5 {- ?2 R( N
$ o+ q- b: M3 j: ~
# 特征提取器
# u* Y, g) v& k, Q& O9 bfor param in model_ft.parameters():3 t+ U" s1 m& S5 n! S% d
    param.requires_grad = False
; o8 w9 s  k$ ~9 H6 c4 M1 s4.2 修改成FCN
4 p9 a# ?; C4 T  }; |/ G/ |5 o/ {2 }& P4 M# o* I4 Q6 q! [
全卷积⽹络(顾名思义全部都是卷积层)先使⽤卷积神经⽹络抽取图像特征,然后通过 1×1​ 1\times 1​1×1​ 卷积层将通道数变换为类别个数,最后通过转置卷积层将特征图的⾼和宽变换为输⼊图像的尺⼨。模型输出与输⼊图像的⾼和宽相同,并在空间位置上⼀⼀对应:) @7 f$ U' R1 \8 F
最终输出的通道包含了该空间位置像素的类别预测。
: K! ~+ z; K2 |0 i  _
4 Z, G2 c! m0 ]# k4 H- `对于转置卷积层,如果步幅为 S​ S​S​、填充为 S/2​ S/2​S/2​ (假设为整数)、卷积核的⾼和宽为 2S​ 2S​2S​,转置卷积核将输⼊的⾼和宽分别放⼤ S​ S​S​ 倍。
( A4 l) ^/ h6 I3 @
! |2 e$ Z& @( C4 @/ ^" ]可以先打印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 倍即可。
# X# ?' `3 y$ K3 K- K1 Y: Y
; X/ O3 w/ ^" ^0 @model_ft = nn.Sequential(*list(model_ft.children())[:-2], # 去掉最后两层" u% D0 T: n( F$ d1 v" O
              nn.Conv2d(512,num_classes,kernel_size=1), # 用大小为1的卷积层改变输出通道为num_class$ L% v. o3 N2 z( Z
              nn.ConvTranspose2d(num_classes,num_classes, kernel_size=64, padding=16, stride=32)).to(device) # 转置卷积层使图像变为输入图像的大小
+ {( J; R& X9 G, H
! h7 h9 u0 [" _( }! I# [/ ]0 Y# _3 @4 N# 对model_ft做一个测试
/ M  w9 w5 l$ J1 K9 B) W# ~x = torch.rand((2,3,320,480), device=device) # 构造随机的输入数据8 f0 s) e* M% v
print(net(x).shape) # 输出依然是 torch.Size([2, 21, 320, 480])
+ b# Q$ E  E/ q/ F. g' {
% T4 _+ W" d9 ~- p2 S- I9 w, s# 打印第一个小批量的类型和形状。不同于图像分类和目标识别,这里的标签是一个三维数组! o8 z( E( K# z6 A- J2 h' [, [9 d
# for X, Y in train_iter:
  g) b9 k- K3 s5 j+ A: W% b#     print(X.dtype, X.shape)) [2 ~1 a& c' k. R$ a) F; J' M9 |
#     print(Y.dtype, Y.shape)
! ]* z% L, s+ u9 g! D#     break; F1 K' k2 @' B% z
6 K3 u2 m- s4 e/ W( F0 Y7 S4 ?: k
+ y, p" r1 C, k- @1 g: R2 A6 r
4.3 初始化转置卷积层
# M1 T7 a9 }) m/ y+ _
/ d% ~- ?4 u; Z' A/ t; E在图像处理中,我们有时需要将图像放⼤,即上采样(upsample)。上采样的⽅法有很多,常⽤的有双线性插值。简单来说,为了得到输出图像
. P1 O" Q, w* E& f% e% `" Q) R% T+ ?在坐标 (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函数构造的卷积核的转置卷积层来实现。8 Z9 u' `" c* w. y. i: x+ `4 P! U) K

) D; V9 b4 _3 C5 R0 J/ R7 I; u* M9 F; Z
# 双线性插值的上采样,用来初始化转置卷积层的卷积核
3 c. |, k6 X7 ^def bilinear_kernel(in_channels, out_channels, kernel_size):
0 w; C4 i5 i! T" U    factor = (kernel_size+1)//2
+ R8 f: O& C0 }( R    if kernel_size%2 == 1:( Z* `# X  @' n8 |6 j
        center = factor-1
$ _; I, r  @, H* k4 U! O9 a  O    else:9 r- g" e  }# q0 x& ^- H' G
        center = factor-0.54 z9 j0 j: i% `8 i$ t. [* c8 S
    og = np.ogrid[:kernel_size, :kernel_size]: H2 M3 W4 E3 P6 L
    filt = (1-abs(og[0]-center)/factor) * (1-abs(og[1]-center)/factor)/ G- g6 Z1 [3 Q1 L1 A4 M+ f  n
    weight = np.zeros((in_channels,out_channels, kernel_size,kernel_size), dtype='float32')6 X6 C% [" f' @9 v9 u) b9 w. x9 B
    weight[range(in_channels), range(out_channels), :, :] = filt2 p0 B# G$ x& A, v+ R9 \
    weight = torch.Tensor(weight)4 `+ P" N  t& m
    weight.requires_grad = True! y7 F9 N- A  g4 I' |) }: Q
    return weight6 G. X- k2 ?- k2 F  a9 o

+ z# q1 F. Y. P" C6 Z4 \. N
* U+ e( E: o0 N1 [在全卷积⽹络中,将转置卷积层初始化为双线性插值的上采样。对于1×1 1\times 11×1卷积层,采⽤Xavier XavierXavier随机初始化。! V  J- g6 a, @) [. z9 C
2 U+ [" R8 V$ B
nn.init.xavier_normal_(model_ft[-2].weight.data, gain=1)
8 [  [( ?( ]! h6 @model_ft[-1].weight.data = bilinear_kernel(num_classes, num_classes, 64).to(device). J8 H9 L. {0 r* r6 z

( Q, O6 p& F$ Q$ @7 w4 _; d1 A5 w5 G- W! a1 _9 H* V
7 B; H% h: ?" |/ B; t1 R
5 训练模型

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


4 a' R! w  i8 Q  w2 }$ I* A$ I$ Rdef train_model(model:nn.Module, criterion, optimizer, scheduler, num_epochs=20):
) y/ y2 P( k% [3 o' J    since = time.time()
: N5 o" U$ c6 ~    best_model_wts = copy.deepcopy(model.state_dict())& v  h( p) E7 G8 S
    best_acc = 0.0
2 d# y: Z8 M8 s9 _$ N0 _    # 每个epoch都有一个训练和验证阶段) \, h" a$ n8 W# z+ T. J# S2 a: o
    for epoch in range(num_epochs):) U0 i" D8 ^& Z( R% O- b4 y0 V2 j
        print('Epoch {}/{}'.format(epoch, num_epochs-1))" l. K3 W1 T0 F
        print('-'*10)
  f$ }5 }- O- @% C        for phase in ['train', 'val']:
5 i2 |8 w2 I8 o            if phase == 'train':
. o) \* _; T$ A5 I                scheduler.step()( }# S! u+ d9 f( r8 W8 h% s
                model.train()
: \) R  |8 l; \# C$ O; \* l            else:
* C3 U0 c9 J. y3 X" G& o6 W                model.eval()/ L0 }3 a- O  L, W
            runing_loss = 0.0
+ L6 T) p; \. p* L; J- a            runing_corrects = 0.0
/ S( D* J  k* t9 D            # 迭代一个epoch& B; k  i, O, E& B
            for inputs, labels in dataloaders[phase]:7 d% M# j9 E" D" A4 }$ Q2 p% ~
                inputs, labels = inputs.to(device), labels.to(device)
. p. m( E' h0 \0 o9 \                optimizer.zero_grad() # 零参数梯度
& D* O6 V  L0 }) d! v) X1 f4 A, f- o& U                                # 前向,只在训练时跟踪参数
* z& Q# x9 P; ^                with torch.set_grad_enabled(phase=='train'):5 {( `9 Z6 F8 ]) s# I- b
                    logits = model(inputs)  # [5, 21, 320, 480]
! x* ^) ?! i+ a) Q                    loss = criteon(logits, labels.long())
- f; n1 ^0 o" i& D5 @8 e                    # 后向,只在训练阶段进行优化% H$ t' B/ B% r" B
                    if phase=='train':# L% p9 Y; {# g! y
                        loss.backward()
; O  q5 S" S# |0 m% q                        optimizer.step()
; S0 Q+ N; Q. V1 K                                # 统计loss和correct
9 B' \# _8 X2 \9 s- M- F  q                runing_loss += loss.item()*inputs.size(0)
3 X& y9 d0 Q  |                runing_corrects += torch.sum((torch.argmax(logits.data,1))==labels.data)/(480*320)
( L- z9 M9 q3 C  }' X" C, t% l+ f! F) O# d& o; J! v* Z
            epoch_loss = runing_loss / dataset_sizes[phase], i  m3 F6 k; _9 o9 b( E
            epoch_acc = runing_corrects.double() / dataset_sizes[phase]
- ^" S# b9 [4 S/ S            print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))
! B8 b4 |) m3 b  V                        # 深度复制model参数
3 j. ]/ \7 Q2 `9 |            if phase=='val' and epoch_acc>best_acc:$ Q9 U5 V" Q  a, C! P% G: u& x' L
                best_acc = epoch_acc
- H) E: y4 Q3 z: M* N' x/ W9 d                best_model_wts = copy.deepcopy(model.state_dict())5 ]7 v4 a4 a" U0 @, W! A
        print()' t/ E. l; U, Z! u+ r; p
    time_elapsed = time.time() - since;
, k# [% ^8 c1 X) z/ y4 G1 J( Q, O    print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed//60, time_elapsed%60))
! B; S- b! L$ x+ S8 h    # 加载最佳模型权重; u! n% K' L+ X! h' ^$ Z3 v
    model.load_state_dict(best_model_wts)6 ], P2 P  w7 [5 X: [# z
    return model; {0 i; @/ `, J8 @: i- |
* y0 h# s7 h; C! O
下面定义train_model要用到的参数,开始训练' h& R- M3 s" |& m0 \+ G3 _

5 w  c7 g/ a( s, a, R4 Fepochs = 5 # 训练5个epoch
& t- G$ F! J- z2 Ncriteon = nn.CrossEntropyLoss()% v0 `0 P7 L( h: q3 {1 h
optimizer = optim.SGD(model_ft.parameters(), lr=0.001, weight_decay=1e-4, momentum=0.9)* l' i6 s& v& u+ V7 l1 C
# 每3个epochs衰减LR通过设置gamma=0.15 ?9 \! ?; ?7 W( E; U
exp_lr_scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)6 L  S+ D3 A  Z
, w1 i" r: [8 x1 l# s
# 开始训练
0 r1 r- \+ O1 I- H! z* Mmodel_ft = train_model(model_ft, criteon, optimizer, exp_lr_scheduler, num_epochs=epochs)  L7 Q6 Y' k6 B
' s" d# ?, K; `3 w
6 测试模型

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

def label2image(pred):
- d. ~5 z5 i4 v! p: x5 `( Y5 y    # pred: [320,480]
$ ?9 p0 k2 C5 }9 b' L    colormap = torch.tensor(VOC_COLORMAP,device=device,dtype=int)4 _# Q4 m7 Z5 b  f! v. E
    x = pred.long()
3 f& ^& y8 G; Q; ^5 {" ?3 ^    return (colormap[x,:]).data.cpu().numpy()
- A: W! h; b& X" K9 \8 e, s! s5 q. G, g* J. M; f6 D( j' |

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

6.1 通用型

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

mean=torch.tensor([0.485, 0.456, 0.406]).reshape(3,1,1).to(device)
+ g/ I  H7 Q0 t0 F. dstd=torch.tensor([0.229, 0.224, 0.225]).reshape(3,1,1).to(device)7 s/ u) Z2 g3 x3 ~. h
def visualize_model(model:nn.Module, num_images=4):
$ u! B3 l2 x1 k" r0 {' `0 ~: r    was_training = model.training( Y, f9 S, Z0 |1 j! X# o
    model.eval()! R* S" a# ^% ^0 B7 V$ r
    images_so_far = 0
& Z  @  F1 d6 R4 s* d3 \5 T    n, imgs = num_images, []
0 e/ Z2 X1 `& e    with torch.no_grad():% M+ n7 y' F  s, W* O6 @3 t
        for i, (inputs, labels) in enumerate(dataloaders['val']):9 e, Q5 m! Q( G. |
            inputs, labels = inputs.to(device), labels.to(device) # [b,3,320,480]: z+ F$ \) G" n5 c9 Q* y( Z0 j
            outputs = model(inputs)7 V2 a2 E6 a" |1 R. A; V1 ~- O
            pred = torch.argmax(outputs, dim=1) # [b,320,480]
& Q1 l2 S5 B0 j: m+ P9 P: Q            inputs_nd = (inputs*std+mean).permute(0,2,3,1)*255 # 记得要变回去哦
& R8 \6 _4 [0 k0 u# W& n
5 w) ?8 r# Q0 w1 w+ h: d! H* Y& ?            for j in range(num_images):
/ c5 M' [# y4 H! x. H                images_so_far += 10 z; U2 {/ W7 F, {! L6 G
                pred1 = label2image(pred[j]) # numpy.ndarray (320, 480, 3)$ ^& J; C" \; t7 |
                imgs += [inputs_nd[j].data.int().cpu().numpy(), pred1, label2image(labels[j])]  M+ G  O) v1 R
                if images_so_far == num_images:; u% j' N! m. ?" M! ?3 i! m2 u
                    model.train(mode=was_training)
3 `: L- O8 E' c( o, E& {0 r2 }                    # 我已经固定了每次只显示4张图了,大家可以自己修改3 W* q/ X: m5 B0 w
                    show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n)* _1 ]8 y; S# A  h
                    return model.train(mode=was_training)) Z3 O/ ?' R% J0 C1 B9 F- h& h
+ ]5 I, B# {& l* A% o
# 开始验证, L; Y- ]" e3 v8 _7 Y0 [
visualize_model(model_ft)  M0 Y/ ]& A; z# z+ w# z$ z
6.2 不通用

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

# 预测前将图像标准化,并转换成(b,c,h,w)的tensor
* Q, r, B1 ]6 Z, A$ |& W0 T& t; i4 n' ldef predict(img, model):& f* }; S  ]' ]$ j6 O  ~3 t
    tsf = transforms.Compose([% |' H& H" c+ w
            transforms.ToTensor(), # 好像会自动转换channel
" A5 K. L2 z; I: p7 }7 U, q5 v            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])])8 L+ m4 W6 o! R0 }$ L+ p3 h
    x = tsf(img).unsqueeze(0).to(device) # (3,320,480) -> (1,3,320,480)
0 V1 C+ \5 O4 ~4 u' d2 K2 p# O9 C    pred = torch.argmax(model(x), dim=1) # 每个通道选择概率最大的那个像素点 -> (1,320,480): q( F7 {4 a3 ^6 n0 \+ A2 _
    return pred.reshape(pred.shape[1],pred.shape[2]) # reshape成(320,480)
5 {8 Y9 V; a4 p0 r  \: t& n) ^. D: ]2 r( t6 `- D
def evaluate(model:nn.Module):, S3 t/ W; R) j0 c; D7 R, G& i7 O4 l
    model.eval()
' }  o' j8 D8 B2 F* E/ Q9 w    test_images, test_labels = read_voc_images(voc_dir, is_train=False, max_num=10)
4 h& K. p/ ]7 i5 C- p: r    n, imgs = 4, []
: w/ y$ s' u( R. Y5 S    for i in range(n):
9 Z' R# y' @# E7 \. q0 c2 G8 w. S        xi, yi = voc_rand_crop(test_images, test_labels, 320, 480) # Image
4 ?4 _* a2 A( L5 \" U  t        pred = label2image(predict(xi, model))
3 M( L# D7 f' W. @' P+ f4 K: g        imgs += [xi, pred, yi]+ O! M1 N& k# |% U+ V4 E  _/ y
    show_images(imgs[::3] + imgs[1::3] + imgs[2::3], 3, n)
# E: Q/ ~7 Q: f+ \  g) }3 Q* r  ?9 z+ Y( r* `6 V* }
# 开始测试& {) e# V! @' D, N/ H' q; w  \
evaluate(model_ft)
& G; u9 l3 p( S) Y' A5 Q5 v8 }9 A% X' A5 g$ W* Q( L7 m
7 结语

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


, ?2 i  V- H' C9 Z7 tEpoch 0/2  l; Z# ?: s4 M) P
----------2 j2 d! q# }$ C0 _0 y& D0 Q
train Loss: 1.7844 Acc: 0.5835
' A$ p$ k4 {. O7 jval Loss: 1.1669 Acc: 0.6456
) g3 l% h4 [. o
- y/ h- R  g) t  m/ B7 @3 mEpoch 1/2" ]% a; d$ k# q4 e, V" C
----------' X% F  Q$ E( e% ?( N9 ~+ s" L
train Loss: 1.1288 Acc: 0.65351 x& x0 M: Y9 o/ j! u! E& U$ W& q/ P
val Loss: 0.9012 Acc: 0.6929
* R7 K+ P! k, e- f# X+ Q/ t+ l9 O: {$ M( \5 E" E4 ?2 \
Epoch 2/2
5 i5 t  w2 K" O" A9 ?. v5 \----------$ |- T  W" ~6 w! Y: f% k
train Loss: 0.9578 Acc: 0.67069 G% S5 a' m' m: Y9 f' i
val Loss: 0.8088 Acc: 0.6948
. B6 p% r" T8 y' u0 |- R: P7 R/ S# o6 d& b" A
Training complete in 6m 37s
, B" d5 U$ I& R3 F5 C. z0 O7 e$ \! I9 V; H/ m* B! ^' g1 v. p5 x

  X1 A+ l  {# V* K 2.jpg " X, J7 t- v: D1 p2 L+ S
! f7 ]& n6 _9 q. g; P
当 epochs = 5 时,训练集的精度在 89 8989% 左右,测试集的精度可以达到 86​ 86​86​ %。% Z6 w4 z$ q6 o( \1 A5 T

' x9 g- E+ `8 h1 l对于这个模型用 ResNet-50 作特征提取器会有更好的效果,不过训练的时间也会更长。还有超参数lr, weight_decay, momentum, step_size, gamma 以及1×1 1×11×1卷积层和转置卷积层的初始化方式也可以继续调。
2 b% y4 r6 `  a. S, I( ?" Z) F; k6 [  y1 z% D
' R  m' w9 z  |9 M4 ~
语义分割还有很多可用的模型,本文用的是 FCN,在其它一些模型上会有更好的表现:# E5 G' _+ ]* Z+ O7 X: `* i0 C' v$ q

3 a+ N' Z2 q9 \) {' aDeeplab V3+ 具有可分离卷积的编码器/解码器,用于语义图像分割[论文]. P2 E3 }7 O# y3 ?# Q
GCN 通过全局卷积网络改进语义分割[论文]; q4 l5 Q/ g8 I& b4 S
UperNet 统一感知解析+ [! i9 l! g. u  _) @/ i- q- E! k
ENet 用于实时语义分割的深度神经网络体系结构[论文]) y: u9 _$ J% C5 ?6 P  H+ I
U-Net 用于生物医学图像分割的卷积网络
1 u( r' Y; I7 p$ [" ?; oSegNet 用于图像分段的深度卷积编码器-解码器架构。
& R; L0 V5 D( B" {# S3 D还有(DUC,HDC)、PSPNet等。
( p1 Q) p* i( G5 t; p7 x
4 B9 |5 b1 X: @; ^: G6 L常用的语义分割数据集也有很多:Pascal VOC、CityScapes、ADE20K、COCO Stuff等。" p4 E0 Z9 K' q1 n
8 A: r8 u6 S) R0 D" a" T
对于损失函数,除了交叉熵误差,也可以用这些:/ \8 N  F' t2 k) X) {& w
2 s  p+ R9 G2 P
Dice-Loss 可以测试两个样本之间的重叠度量,可以更好地反映训练目标,但该损失函数具有很强的非凸性,很难优化。
- V) ~& ]! ^* R9 G- zCE Dice loss Dice 损失与 CE 的总和,CE 提供了平滑的优化,而 Dice 损失则很好地表明了分割结果的质量。
. q  G7 T" a9 Q7 T/ T/ C! z, {Focal Loss CE 的另一种版本,用于避免类别不平衡而降低了置信度的情况。
0 _1 @7 z& B9 u, H9 K# _# @( `9 {Lovasz Softmax 查看论文:Lovasz - softmax损失。3 M* [1 a; x4 }2 c
' T6 D' [7 D- v0 g( Z
4 f. O# B, ]9 ~, z- D
8 a# C* r! @6 m2 J1 ~& [% d
' x, n! e# j9 ]6 R2 y% g# O0 O6 v

2 q" Q' q7 s+ y1 ?( q& a! ?: g& o————————————————% ]: B$ \; }* k4 z1 I5 }
版权声明:本文为CSDN博主「小红不吃糖」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。5 ], E" k: R: i- X7 Y$ _' i! T
原文链接:https://blog.csdn.net/qq_43280818/article/details/105916507
7 m$ b, ^, K; r
8 A/ `) h0 W# u
3 Z. c) u# M' K8 l7 `% |) l0 J$ T, f9 J$ n9 a





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