目次
最近、画像分野における画像セグメンテーションの関連知識について少し調べましたが、FCN がこの分野のほぼ創始者であるため、水はまだかなり深いことがわかりました。そこで、最初にこの側面から始めます。理論についてはあまり話しませんが、主にコード部分を分析するためにインターネットでたくさん検索しました。
モデルの初期化
ご存知のとおり、FCN は後半が新しく、前半は他のモデルからの移植が一般的ですが、ここでは Vgg16 のモデル構成を選択します。したがって、モデルの初期化は 2 つのステップに分かれており、最初は Vgg ネットワークの初期化、次に Fcn ネットワークの初期化です。
VGGの初期化
ここで、Vgg 選択の便宜上、いくつかの異なる Vgg がリストにカプセル化されており、数字は畳み込み後の出力チャネル数を表します。畳み込みの入力チャネル数は、前の出力チャネル数です。M は、 Vgg は、畳み込みカーネル 3 の畳み込み層とサイズ 2 のプーリング層カーネルを使用するため、これら 2 つのパラメーターは既知であり、マークする必要はありません。
Fcn は完全な畳み込みネットワークであるため、最後の完全に接続された層は必要ないため、削除されることにも注意してください。
# Vgg网络结构配置(数字代表经过卷积后的channel数,‘M’代表池化层)
cfg = {
'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'],
'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}
# 由cfg构建vgg-Net的卷积层和池化层(block1-block5)
def make_layers(cfg, batch_norm=False):
layers = []
in_channels = 3 # RGB初始值
for v in cfg:
if v == 'M': # 池化层
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
if batch_norm: # 是否需要归一化
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v # 这一层输出的通道数就是下一层输入的通道数
return nn.Sequential(*layers)
# 下面开始构建VGGnet
class VGGNet(VGG):
def __init__(self, pretrained=True, model='vgg16', requires_grad=True, remove_fc=True, show_params=False):
super().__init__(make_layers(cfg[model]))
self.ranges = ranges[model] # ranges是一个字典,键是model名字,后面的是池化层的信息
# 获取VGG模型训练好的参数,并加载(第一次执行需要下载一段时间)
if pretrained:
exec("self.load_state_dict(models.%s(pretrained=True).state_dict())" % model)
# 屏蔽预训练模型的权重,只训练最后一层的全连接的权重,因为fcn模型是建立在vgg16基础上训练的,所以前面训练好的VGG网络不修改
if not requires_grad:
for param in super().parameters():
param.requires_grad = False
# 去掉vgg最后的全连接层(classifier)
if remove_fc:
del self.classifier
# 打印网络的结构
if show_params == True:
for name, param in self.named_parameters():
print(name, param.size())
def forward(self, x):
output = {}
# 利用之前定义的ranges获取每个max-pooling层输出的特征图,这个主要是FCN32的上采样要用到
for idx, (begin, end) in enumerate(self.ranges): # enumerate用于枚举,同时给出元素和下标
# self.ranges = ((0, 5), (5, 10), (10, 17), (17, 24), (24, 31)) (vgg16 examples)
for layer in range(begin, end):
x = self.features[layer](x)
# 相当于把x矩阵放进layer层,然后得到输出,0-5代表第一个max-pool需要经过的层数,所以x1实际上就是第一个max-pool层输出
output["x%d" % (idx + 1)] = x
# x数字越大越深
# output 为一个字典键x1d对应第一个max-pooling输出的特征图,x2...x5类推
return output
Fcn8s は最初の 3 つのプーリング層の情報を融合する必要があるため、Vgg モデルのプーリング層の情報を記録する必要があります。最後のプーリング層に移動し、終了後に結果を辞書に保存し、最後に Vgg モデルのプーリング層の情報を保存します。出力には複数のプーリング層が含まれます (毎回プーリング層で終わるため)。
FCNの初期化
次にFCNネットワークの初期化についてです。次の図に示すように、FCN の下には FCN32、FCN16、および FCN8 があります。
これは FCN8s です。異なる深さのプーリング層の情報が融合されるため、エッジ処理は直接出力よりもスムーズになります。これは、浅い抽象化レベルの方が詳細をよく理解していることが多いためです。ただし、融合が多ければ多いほど良いというわけではなく、Fcn4sに比べて精度が大幅に向上するわけではないので十分だと作者も言っていたので、以下はFcn8sを直接やります。
# 下面由VGG构建FCN8s
class FCN8s(nn.Module):
def __init__(self, pretrained_net, n_class):
super().__init__()
# 定义可能会用到的东西
self.n_class = n_class
self.pretrained_net = pretrained_net
self.conv6 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1)
self.conv7 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1) # 卷积核大小是1,本质上是全连接层
# 这里写两个一样的可能是为了写出前后关系的感觉?
self.relu = nn.ReLU(inplace=True)
self.deconv1 = nn.ConvTranspose2d(512, 512, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn1 = nn.BatchNorm2d(512)
self.deconv2 = nn.ConvTranspose2d(512, 256, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn2 = nn.BatchNorm2d(256)
self.deconv3 = nn.ConvTranspose2d(256, 128, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.deconv4 = nn.ConvTranspose2d(128, 64, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn4 = nn.BatchNorm2d(64)
self.deconv5 = nn.ConvTranspose2d(64, 32, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn5 = nn.BatchNorm2d(32)
self.classifier = nn.Conv2d(32, n_class, kernel_size=1)
def forward(self, x):
output = self.pretrained_net(x)
# 这个已经在前面的forward中初始化了,里面已经存储了相关特征图
x5 = output['x5'] # max-pooling5的feature map (1/32) 5*5,160/32
# print(x5.size())
x4 = output['x4'] # max-pooling4的feature map (1/16)
x3 = output['x3'] # max-pooling3的feature map (1/8)
# 所以总结一下FCN里面的几个合成的步骤也就是反卷积->激活->标准化->加上前面的pool层继续
score = self.relu(self.conv6(x5)) # conv6 size不变 (1/32)
# score = self.relu(self.conv7(score)) # conv7 size不变 (1/32)
# 这里我尝试把右边括号里的x5改成了score
score = self.relu(self.deconv1(score)) # out_size = 2*in_size (1/16)
# print(score.size()) # 反卷积之后变为两倍
score = self.bn1(score + x4) # bn是标准化,表示加x4第二池化层的结果一同进行计算
score = self.relu(self.deconv2(score)) # out_size = 2*in_size (1/8)
score = self.bn2(score + x3)
# 到这里为止就是全部的FCN步骤,接下来是反卷积到原尺寸
# 此时是1/8,然后继续反卷积,每次扩大两倍边长直到最后和原图一样
score = self.bn3(self.relu(self.deconv3(score))) # out_size = 2*in_size (1/4),反卷积后标准化
score = self.bn4(self.relu(self.deconv4(score))) # out_size = 2*in_size (1/2)
score = self.bn5(self.relu(self.deconv5(score))) # out_size = 2*in_size (1)
score = self.classifier(score) # size不变,使输出的channel等于类别数,相当于对每个点分类
return score
コードは他のブロガーから引用したものであるため、読み取りプロセス中にいくつかの問題も発生しました。元のコードはスコアを次のように処理しますが、最初と 2 番目の文がスコア用に処理された後、3 番目の文が次のようになっていることがわかります。また、処理されます。スコアは再割り当てされますが、これは何を意味しますか? 最初の 2 つの文は無効であり、これも私が混乱しているところです。後で、このブロガーが参照している github のソース コードも参照します。処理済みスコア に入って処理を続けると、上記のようになります。
score = self.relu(self.conv6(x5)) # conv6 size不变 (1/32)
score = self.relu(self.conv7(score)) # conv7 size不变 (1/32)
score = self.relu(self.deconv1(x5)) # out_size = 2*in_size (1/16)
score = self.bn1(score + x4)
score = self.relu(self.deconv2(score)) # out_size = 2*in_size (1/8)
score = self.bn2(score + x3)
score = self.bn3(self.relu(self.deconv3(score))) # out_size = 2*in_size (1/4)
score = self.bn4(self.relu(self.deconv4(score))) # out_size = 2*in_size (1/2)
score = self.bn5(self.relu(self.deconv5(score))) # out_size = 2*in_size (1)
score = self.classifier(score) # size不变,使输出的channel等于类别数
各プーリング サイズは半分になり、後続のデコンボリューションごとにサイズが 2 倍になるため、処理は最終的に元のサイズになります。
画像の前処理
次のステップは、画像処理や画像エンコードなどのトレーニング画像の前処理です。
画像処理
画像自体の処理はサイズ変換や標準化、パッケージ化などが主で、基本的にライブラリ関数で完結するので多くは語りません。
画像エンコーディング
エンコードは比較的面倒で、損失関数の計算が使用される可能性があるため、ワンホット エンコードが必要です。
ワンホット エンコーディングは、対応する次元に 1、残りに 0 である n 個の位置を空けることです。たとえば、性別は男性/女性で、男性が 1 番目、女性が 2 番目で、個人の場合、性別は男性 (コード 10) または女性、コード 01 にすることができ、国籍は中国/アメリカであると仮定します。 /Japanese の場合、その人の国籍コードは 100,010,001 (中国、米国、日本) になります。つまり、1 つは常に 1、その他は 0、そして 1 であるものがその人の国籍に対応します。ここでのカテゴリも同様で、ピクセルが 2 つあり、各ピクセルは最初のカテゴリに属する 01 か、2 番目のカテゴリに属する 10 のいずれかであると仮定します。
ワンホットエンコーディングは次のとおりです。
def onehot(data, n):
buf = np.zeros(data.shape + (n,)) # 相当于给每一个像素开辟一个维度,除了他其他都是其他
nmsk = np.arange(data.size) * n + data.ravel() # revel表示展平多维数组,就是flatten
# 前面的data.size是从第一个元素到最后一个元素(所有),下标0--n-1,表示的是行,乘一行个数n就是在在一维数组中一行的开始位置
buf.ravel()[nmsk] = 1 # 这个就是表示把对应的是1的(根据上面nmsk找到的索引值)值给buf
return buf
この関数が何をするのか説明してください。渡されるパラメータは、160*160 の画像などの画像です。本質はもちろんデジタル マトリックスです。次に、各ピクセルをエンコードする必要があります。2 つのカテゴリがあるため、各ピクセルは2 つの位置なので、次元 n を追加し、すべて 0 に設定します。これはエンコード前の行列で、サイズは 160*160*2 です。nmsk は、平坦化された非エンコード行列内の各ピクセルに対応するカテゴリの位置を保存します。
たとえば、現在のピクチャが 2 次元行列 ([[0,1,0],[1,1,0],[0,0,1]]) の場合、buf は 3*3*2 です。行列、すべて 0、最初のピクセルは 0、つまりカテゴリが最初であるため、このピクセルは [1,0] でコード化され、2 番目のピクセルは 1 で [0,1] でコード化されます。これも同じ理由です。最後に、初期行列の各要素をエンコードされたものに置き換えるだけで、最終的には [[[1,0],[0,1]...]] になりますが、これを書くのは簡単ではありません。最初に 1 の位置を記録し、最後にそれを直接置き換えることができます。平坦化されたエンコードされた行列の最初の 4 つは 1001 です。それがどのように生まれたのか説明しましょう。最初のピクセル コードは 10、そして最終的な平坦化された行列におけるこの 1 の位置は 0=0*2+0 で、2 番目の位置は 0=0*2+0 です。 1 の は 3=1*2+1 なので、アルゴリズムは次のようになります。
WZ (1 の最終位置) = WZ (ピクセル インデックス) * カテゴリの数 + ピクセルのカテゴリ
したがって、nmsk を使用してこれらの 1 の位置を記録し、最後に対応する位置の 0 を 1 に置き換えることで、画像ピクセルのエンコードが完了します。
エンコードされた画像を元の画像に復元する方法は非常に簡単です。1 の位置で十分かどうかを確認するだけです。この次元の最大値の位置、つまり argmax() 関数を見つけるだけです。次のとおりです。は簡単なデモです:
imgB = np.array([1, 0, 1, 1, 0, 1, 1, 0, 0]).reshape(3, 3)
print('编码前:\n', imgB)
imgB = onehot(imgB, 2)
# print('2:', imgB)
print('恢复:\n', np.argmax(imgB, 2))
効果は以下の通りです
これは、以下のトレーニング コードに示されています。
関連パラメータの計算
ここで関連するパラメータは精度 acc と iou を指しますが、その他については慎重に計算していないため、主に精度について説明します。
コードは以下のように表示されます。
# 在训练网络前定义函数用于计算Acc 和 mIou
# 计算混淆矩阵
def _fast_hist(label_true, label_pred, n_class):
mask = (label_true >= 0) & (label_true < n_class) # 查找有效类别,mask是个bool类型向量
# 计算匹配个数
hist = np.bincount( # bincount输出每个元素的数量,np.bincount([1,1,2]) 输 出 : [0,2,1]代表0有0个,1有2个,2有1个
n_class * label_true[mask].astype(int) + # astype代表把bool转为int
label_pred[mask], minlength=n_class ** 2).reshape(n_class, n_class) # minlength=4表示最少计算到class*2,为0也计算,不然个数都不够
'''
混淆矩阵 n_class = 2,矩阵2*2
0 1 标答
0 0*2+0 0*2+1
1 1*2+0 1*2+1
预测
一维向量的输出是 0,1,2,3,对应到矩阵中
'''
return hist
# 根据混淆矩阵计算Acc和mIou
def label_accuracy_score(label_trues, label_preds, n_class):
"""
Returns accuracy score evaluation result.
- overall accuracy
- mean accuracy
- mean IU
"""
hist = np.zeros((n_class, n_class))
for lt, lp in zip(label_trues, label_preds): # zip(a,b)就是一一对应打包起来
hist += _fast_hist(lt.flatten(), lp.flatten(), n_class) # 展平送进去计算,也就是向量计算
acc = np.diag(hist).sum() / hist.sum() # 计算主对角线的,也就是正确的数量
with np.errstate(divide='ignore', invalid='ignore'):
acc_cls = np.diag(hist) / hist.sum(axis=1)
acc_cls = np.nanmean(acc_cls)
with np.errstate(divide='ignore', invalid='ignore'):
iu = np.diag(hist) / (
hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist)
)
mean_iu = np.nanmean(iu)
freq = hist.sum(axis=1) / hist.sum()
return acc, acc_cls, mean_iu
これには混同行列を計算するという問題が含まれますが、混同行列自体は非常に単純で、00、01、10、11 の一致数を計算するだけです。01 はラベルが 0 であることを意味しますが、予測は 1 です。 、もう一方も同じです。
入力パラメータはマークアップ行列、予測行列、およびカテゴリの数です。コメントから、展開された 4 つの位置 0、1、2、および 3 は 0*2+0、0*2+1、1* であることがわかります。 +0、1*2+1なので、このとき予測値を行ラベル、その答えを列ラベルとして、4つの中で0~1が一致する数を簡単に計算します。ポジション。冒頭のムスクは3の予測など無効な座標を排除するためだったのだろうが、実際にはそのようなカテゴリがなければ計算する必要はない。
acc の計算に関しては、予測と答えが一致していれば正しいはずなので、主対角線を合計して総ピクセル数で割ります。
モデルトレーニング
上記は関連するすべてのホイールであり、最終的に組み立てを開始します。つまり、モデルのトレーニングが開始されます。
モデルのトレーニングも実際は同様で、オプティマイザー、損失関数を設定し、トレーニング ラウンド数を設定してトレーニングを開始します。
def train(epo_num=50, show_vgg_params=False):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
if torch.cuda.is_available():
print('使用GPU')
else:
print('使用CPU')
vgg_model = VGGNet(requires_grad=True, show_params=show_vgg_params)
fcn_model = FCN8s(pretrained_net=vgg_model, n_class=2) # 把训练好的几个maxpool层的集合传给fcn
fcn_model = fcn_model.to(device) # 载入模型
# 这里只有两类,采用二分类常用的损失函数BCE
criterion = nn.BCELoss().to(device)
# 随机梯度下降优化,学习率0.001,惯性分数0.7
optimizer = optim.SGD(fcn_model.parameters(), lr=1e-3, momentum=0.7)
# 记录训练过程相关指标
all_train_iter_loss = []
all_test_iter_loss = []
test_Acc = []
test_mIou = []
# start timing
prev_time = datetime.now()
for epo in range(1, epo_num + 1):
pbar = tqdm(train_dataloader) # 要先把训练集转进进度条里面
# 训练
train_loss = 0 # 一轮的总误差,全部图片的
fcn_model.train()
for index, (bag, bag_msk) in enumerate(pbar):
bag = bag.to(device)
bag_msk = bag_msk.to(device)
optimizer.zero_grad() # 梯度清零
output = fcn_model(bag) # 输出
# print(output.shape)
output = torch.sigmoid(output) # output.shape is torch.Size([4, 2, 160, 160])
loss = criterion(output, bag_msk) # 计算和标答的误差
# print('loss=',loss)
loss.backward() # 需要计算导数,则调用backward()
# print('grad_loss=',loss)
iter_loss = loss.item() # .item()返回一个具体的值,一般用于loss和acc,这一张的误差
all_train_iter_loss.append(iter_loss) # 把误差放进误差列表,方便最后画图
train_loss += iter_loss # 加到一轮总的误差里
optimizer.step() # 根据求导得到的进行更新
output_np = output.cpu().detach().numpy().copy()
bag_msk_np = bag_msk.cpu().detach().numpy().copy()
bag_msk_np = np.argmax(bag_msk_np, axis=1)
info = 'epoch {}, {}/{},train loss is {}'.format(epo, index, len(train_dataloader), iter_loss)
pbar.set_description(info)
# 验证
test_loss = 0
fcn_model.eval()
with torch.no_grad():
for index, (bag, bag_msk) in enumerate(test_dataloader):
bag = bag.to(device)
bag_msk = bag_msk.to(device)
optimizer.zero_grad()
output = fcn_model(bag)
output = torch.sigmoid(output) # output.shape is torch.Size([4, 2, 160, 160])
loss = criterion(output, bag_msk)
iter_loss = loss.item()
all_test_iter_loss.append(iter_loss)
test_loss += iter_loss # 计算并记录误差
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmax(output_np, axis=1)
bag_msk_np = bag_msk.cpu().detach().numpy().copy()
# 计算时间
cur_time = datetime.now()
# divmod(x,y)返回一个元组,第一个参数是整除的结果,第二个是取模的结果
h, remainder = divmod((cur_time - prev_time).seconds, 3600)
m, s = divmod(remainder, 60)
time_str = "Time %02d:%02d:%02d" % (h, m, s) # 时分秒
prev_time = cur_time # 更新时间
info = 'epoch: %d, epoch train loss = %f, epoch test loss = %f, %s' \
% (epo, train_loss / len(train_dataloader), test_loss / len(test_dataloader), time_str)
print(info)
acc, acc_cls, mean_iu = label_accuracy_score(bag_msk_np, output_np, 2)
test_Acc.append(acc)
test_mIou.append(mean_iu)
print('Acc = %f, mIou = %f' % (acc, mean_iu))
# 每2个epoch存储一次模型
if np.mod(epo, 2) == 0:
# 只存储模型参数
torch.save(fcn_model.state_dict(), './pths/fcn_model_{}.pth'.format(epo))
print('成功存储模型:fcn_model_{}.pth'.format(epo))
小さな問題
本文は以上で終わりですが、まだ質問があります。上記の FCN モデルの小さな問題に加えて、onehot() の nmsk の計算について別の場所があります。原著者のコードは次のとおりです:
def onehot(data, n):
buf = np.zeros(data.shape + (n,)) # 相当于给每一个像素开辟一个维度,除了他其他都是其他
nmsk = np.arange(data.size) * n + data.ravel() # revel表示展平多维数组,就是flatten
buf.ravel()[nmsk-1] = 1 # 这个就是表示把对应的是1的(根据上面nmsk找到的索引值)值给buf
return buf
違いは、ここでの nmsk には -1 が付いており、行列を復元するときに argmax() 関数の代わりに argmin() 関数が選択されることですが、実際にはこのようなセットを使用して 3*3 行列をエンコードしています。マトリックスが変わりました。
復元できないことがわかりますが、奇妙なのは、このルールを使用して復元された画像を確認したことです (下の画像の中央がラベル、左側が nmsk-1 と argmin() の組み合わせです) 、右が nmsk と argmax() の組み合わせ)
違反や回復はありませんでした。えっと?? ? まだこのような手術はありますか?問題はないようです。なぜそれが可能なのかは、なぜ不可能なのかよりも奇妙であることは誰もが知っています。私はそれを理解できませんでしたが、おそらく次のような写真自体の特別な特性と関係があるのではないかと考えました。
この種の画像は、まず 1 か 0 の 2 値分類であるため、最大値 1 を見つけて最小値を見つける機会、つまり 0 を見つける機会も与えられ、argmin() を使用して出力が得られます。時間は白黒逆転するはずなのに、実は違うのに、なぜ?nmsk-1だから。
エンコード オブジェクトが 111000 であると仮定すると、通常のエンコード後のフラット化は 01 01 01 10 10 10 になるはずですが、nmsk-1 のため、すべての 1 の位置を前方に移動する必要があり、最初の 1 の位置は -1 になり、最後に次のようになります。最終的なエンコード結果は 10 10 11 01 01 00 で、この時点で、2 つの近傍間の最小インデックスは 1、1、0、0、0、0 であることがわかり、次の 2 つの点が見つかります。
1 つは通常の回復のほとんどです。なぜなら、0101.. は先に進むと 1010 になるからです... 次に、最小値を見つけて、元の 1 を 0 に置き換えます。そのため、現在の最小値の検索は、元の最大値の検索と同等になります。
2 つ目は、3 番目の 1 リカバリ エラーです。これは、移動によって 2 つの 01 が 0110 としてエンコードされ、その後 11 が 1 に対応する位置に移動され、argmin() がその最初のインデックスを出力するためです。同じパラメータ 添字が 0 になり、エラーが復元される 上記のプログラム結果からも同様で、10 のジャンクションごとに 1 が 0 に復元され、エラーになります。
では、なぜ上記の画像は問題なく回復するのでしょうか? 答えは、画像内に 10 個の境界線が少なすぎるためです。そのほとんどは 000...111...000...111... であり、このエラーを引き起こす数ピクセルは最終結果にほとんど影響しません。
完全なコード
import os
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset, random_split
from torchvision import transforms
from torchvision.models.vgg import VGG
import cv2
import numpy as np
from tqdm import tqdm
# 将标记图(每个像素值代该位置像素点的类别)转换为onehot编码
def onehot(data, n):
buf = np.zeros(data.shape + (n,)) # 相当于给每一个像素开辟一个维度,除了他其他都是其他
nmsk = np.arange(data.size) * n + data.ravel() # revel表示展平多维数组,就是flatten
# 前面的data.size是从第一个元素到最后一个元素(所有),下标0--n-1,表示的是行,乘一行个数n就是在在一维数组中一行的开始位置
# 后面的是0--n-1表示的是类别,表示第几个
# 索引nmsk存储了在一维数组中应该是1的位置,也就是正确答案
buf.ravel()[nmsk-1] = 1 # 这个就是表示把对应的是1的(根据上面nmsk找到的索引值)值给buf
return buf
# 利用torchvision提供的transform,定义原始图片的预处理步骤(转换为tensor和标准化处理)
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])])
# 利用torch提供的Dataset类,定义我们自己的数据集
base_img = './data/bag_data/' # 训练集地址
base_img_msk = './data/bag_data_msk/' # 标注地址
class BagDataset(Dataset):
def __init__(self, transform=None):
self.transform = transform
def __len__(self):
return len(os.listdir(base_img))
def __getitem__(self, idx):
img_name = os.listdir(base_img)[idx] # index是随机数,是图片的索引值
imgA = cv2.imread(base_img + img_name)
imgA = cv2.resize(imgA, (160, 160))
# img_name = '1.jpg'
imgB = cv2.imread(base_img_msk + img_name, 0)
imgB = cv2.resize(imgB, (160, 160))
# 下面是对标注的一些处理
imgB = imgB / 255 # 归一化
imgB = imgB.astype('uint8') # 转化成整数
imgB = onehot(imgB, 2)
imgB = imgB.transpose(2, 0, 1) # 转置 0 1 2 -> 2 0 1 相当于几个维度的位置关系变化,就是把一开始加到最后的提到最前面,效果就是把两列的每一列变成一张图
imgB = torch.FloatTensor(imgB)
if self.transform:
imgA = self.transform(imgA)
return imgA, imgB
# 实例化数据集
bag = BagDataset(transform)
train_size = int(0.9 * len(bag))
test_size = len(bag) - train_size
train_dataset, test_dataset = random_split(bag, [train_size, test_size]) # 划分数据集
# 利用DataLoader生成一个分batch获取数据的可迭代对象
train_dataloader = DataLoader(train_dataset, batch_size=4, shuffle=True, num_workers=4)
test_dataloader = DataLoader(test_dataset, batch_size=4, shuffle=True, num_workers=4)
# <-------------------------------------------------------->#
# 下面开始定义网络模型
# 先定义VGG结构
# ranges 是用于方便获取和记录每个池化层得到的特征图
# 例如vgg16,需要(0, 5)的原因是为方便记录第一个pooling层得到的输出(详见下午、稳VGG定义)
ranges = {
'vgg11': ((0, 3), (3, 6), (6, 11), (11, 16), (16, 21)),
'vgg13': ((0, 5), (5, 10), (10, 15), (15, 20), (20, 25)),
'vgg16': ((0, 5), (5, 10), (10, 17), (17, 24), (24, 31)),
'vgg19': ((0, 5), (5, 10), (10, 19), (19, 28), (28, 37))
}
# Vgg网络结构配置(数字代表经过卷积后的channel数,‘M’代表池化层)
cfg = {
'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'],
'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}
# 由cfg构建vgg-Net的卷积层和池化层(block1-block5)
def make_layers(cfg, batch_norm=False):
layers = []
in_channels = 3 # RGB初始值
for v in cfg:
if v == 'M': # 池化层
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
if batch_norm: # 是否需要归一化
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v # 这一层输出的通道数就是下一层输入的通道数
return nn.Sequential(*layers)
# 下面开始构建VGGnet
class VGGNet(VGG):
def __init__(self, pretrained=True, model='vgg16', requires_grad=True, remove_fc=True, show_params=False):
super().__init__(make_layers(cfg[model]))
self.ranges = ranges[model] # ranges是一个字典,键是model名字,后面的是池化层的信息
# 获取VGG模型训练好的参数,并加载(第一次执行需要下载一段时间)
if pretrained:
exec("self.load_state_dict(models.%s(pretrained=True).state_dict())" % model)
# 屏蔽预训练模型的权重,只训练最后一层的全连接的权重,因为fcn模型是建立在vgg16基础上训练的,所以前面训练好的VGG网络不修改
if not requires_grad:
for param in super().parameters():
param.requires_grad = False
# 去掉vgg最后的全连接层(classifier)
if remove_fc:
del self.classifier
# 打印网络的结构
if show_params == True:
for name, param in self.named_parameters():
print(name, param.size())
def forward(self, x):
output = {}
# 利用之前定义的ranges获取每个max-pooling层输出的特征图,这个主要是FCN32的上采样要用到
for idx, (begin, end) in enumerate(self.ranges): # enumerate用于枚举,同时给出元素和下标
# self.ranges = ((0, 5), (5, 10), (10, 17), (17, 24), (24, 31)) (vgg16 examples)
for layer in range(begin, end):
x = self.features[layer](x)
# 相当于把x矩阵放进layer层,然后得到输出,0-5代表第一个max-pool需要经过的层数,所以x1实际上就是第一个max-pool层输出
output["x%d" % (idx + 1)] = x
# x数字越大越深
# output 为一个字典键x1d对应第一个max-pooling输出的特征图,x2...x5类推
return output
# 下面由VGG构建FCN8s
class FCN8s(nn.Module):
def __init__(self, pretrained_net, n_class):
super().__init__()
# 定义可能会用到的东西
self.n_class = n_class
self.pretrained_net = pretrained_net
self.conv6 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1)
self.conv7 = nn.Conv2d(512, 512, kernel_size=1, stride=1, padding=0, dilation=1) # 卷积核大小是1,本质上是全连接层
# 这里写两个一样的可能是为了写出前后关系的感觉?
self.relu = nn.ReLU(inplace=True)
self.deconv1 = nn.ConvTranspose2d(512, 512, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn1 = nn.BatchNorm2d(512)
self.deconv2 = nn.ConvTranspose2d(512, 256, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn2 = nn.BatchNorm2d(256)
self.deconv3 = nn.ConvTranspose2d(256, 128, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn3 = nn.BatchNorm2d(128)
self.deconv4 = nn.ConvTranspose2d(128, 64, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn4 = nn.BatchNorm2d(64)
self.deconv5 = nn.ConvTranspose2d(64, 32, kernel_size=3, stride=2, padding=1, dilation=1, output_padding=1)
self.bn5 = nn.BatchNorm2d(32)
self.classifier = nn.Conv2d(32, n_class, kernel_size=1)
def forward(self, x):
output = self.pretrained_net(x)
# 这个已经在前面的forward中初始化了,里面已经存储了相关特征图
x5 = output['x5'] # max-pooling5的feature map (1/32) 5*5,160/32
x4 = output['x4'] # max-pooling4的feature map (1/16)
x3 = output['x3'] # max-pooling3的feature map (1/8)
# 所以总结一下FCN里面的几个合成的步骤也就是反卷积->激活->标准化->加上前面的pool层继续
# 这两句没用,或者说用错了
score = self.relu(self.conv6(x5)) # conv6 size不变 (1/32)
# 1/32可能没有融合进去?
# 这里我尝试把右边括号里的x5改成了score
score = self.relu(self.deconv1(score)) # out_size = 2*in_size (1/16)
score = self.bn1(score + x4) # bn是标准化,表示加x4第二池化层的结果一同进行计算
score = self.relu(self.deconv2(score)) # out_size = 2*in_size (1/8)
score = self.bn2(score + x3)
# 到这里为止就是全部的FCN步骤,接下来是反卷积到原尺寸
# 此时是1/8,然后继续反卷积,每次扩大两倍边长直到最后和原图一样
score = self.bn3(self.relu(self.deconv3(score))) # out_size = 2*in_size (1/4),反卷积后标准化
score = self.bn4(self.relu(self.deconv4(score))) # out_size = 2*in_size (1/2)
score = self.bn5(self.relu(self.deconv5(score))) # out_size = 2*in_size (1)
score = self.classifier(score) # size不变,使输出的channel等于类别数,相当于对每个点分类
# print(score.shape)
# time.sleep(1000)
return score
# <---------------------------------------------->
# 下面开始训练网络
# 在训练网络前定义函数用于计算Acc 和 mIou
# 计算混淆矩阵
def _fast_hist(label_true, label_pred, n_class):
mask = (label_true >= 0) & (label_true < n_class) # 查找有效类别,mask是个bool类型向量
# 计算匹配个数
hist = np.bincount( # bincount输出每个元素的数量,np.bincount([1,1,2]) 输 出 : [0,2,1]代表0有0个,1有2个,2有1个
n_class * label_true[mask].astype(int) + # astype代表把bool转为int
label_pred[mask], minlength=n_class ** 2).reshape(n_class, n_class) # minlength=4表示最少计算到class*2,为0也计算,不然个数都不够
'''
混淆矩阵 n_class = 2,矩阵2*2
0 1 标答
0 0*2+0 0*2+1
1 1*2+0 1*2+1
预测
一维向量的输出是 0,1,2,3,对应到矩阵中
'''
return hist
# 根据混淆矩阵计算Acc和mIou
def label_accuracy_score(label_trues, label_preds, n_class):
"""
Returns accuracy score evaluation result.
- overall accuracy
- mean accuracy
- mean IU
"""
hist = np.zeros((n_class, n_class))
for lt, lp in zip(label_trues, label_preds): # zip(a,b)就是一一对应打包起来
hist += _fast_hist(lt.flatten(), lp.flatten(), n_class) # 展平送进去计算,也就是向量计算
acc = np.diag(hist).sum() / hist.sum() # 计算主对角线的,也就是正确的数量
with np.errstate(divide='ignore', invalid='ignore'):
acc_cls = np.diag(hist) / hist.sum(axis=1)
acc_cls = np.nanmean(acc_cls)
with np.errstate(divide='ignore', invalid='ignore'):
iu = np.diag(hist) / (
hist.sum(axis=1) + hist.sum(axis=0) - np.diag(hist)
)
mean_iu = np.nanmean(iu)
freq = hist.sum(axis=1) / hist.sum()
return acc, acc_cls, mean_iu
from datetime import datetime
import torch.optim as optim
import matplotlib.pyplot as plt
def train(epo_num=50, show_vgg_params=False):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
if torch.cuda.is_available():
print('使用GPU')
else:
print('使用CPU')
vgg_model = VGGNet(requires_grad=True, show_params=show_vgg_params)
fcn_model = FCN8s(pretrained_net=vgg_model, n_class=2) # 把训练好的几个maxpool层的集合传给fcn
fcn_model = fcn_model.to(device) # 载入模型
# 这里只有两类,采用二分类常用的损失函数BCE
criterion = nn.BCELoss().to(device)
# 随机梯度下降优化,学习率0.001,惯性分数0.7
optimizer = optim.SGD(fcn_model.parameters(), lr=1e-3, momentum=0.7)
# 记录训练过程相关指标
all_train_iter_loss = []
all_test_iter_loss = []
test_Acc = []
test_mIou = []
# start timing
prev_time = datetime.now()
for epo in range(1, epo_num + 1):
pbar = tqdm(train_dataloader) # 要先把训练集转进进度条里面
# 训练
train_loss = 0 # 一轮的总误差,全部图片的
fcn_model.train()
for index, (bag, bag_msk) in enumerate(pbar):
bag = bag.to(device)
bag_msk = bag_msk.to(device)
optimizer.zero_grad() # 梯度清零
output = fcn_model(bag) # 输出
# print(output.shape)
# time.sleep(1000)
output = torch.sigmoid(output) # output.shape is torch.Size([4, 2, 160, 160])
loss = criterion(output, bag_msk) # 计算和标答的误差
# print('loss=',loss)
loss.backward() # 需要计算导数,则调用backward()
# print('grad_loss=',loss)
iter_loss = loss.item() # .item()返回一个具体的值,一般用于loss和acc,这一张的误差
all_train_iter_loss.append(iter_loss) # 把误差放进误差列表,方便最后画图
train_loss += iter_loss # 加到一轮总的误差里
optimizer.step() # 根据求导得到的进行更新
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmax(output_np, axis=1) # 找出所有通道里面的最小值
# 相当于就是把两个维度的最小值的找到作为输出,也就是找的是0在两个索引中的位置,本质也是在找1的位置
bag_msk_np = bag_msk.cpu().detach().numpy().copy()
bag_msk_np = np.argmax(bag_msk_np, axis=1)
info = 'epoch {}, {}/{},train loss is {}'.format(epo, index, len(train_dataloader), iter_loss)
pbar.set_description(info)
# 验证
test_loss = 0
fcn_model.eval()
with torch.no_grad():
for index, (bag, bag_msk) in enumerate(test_dataloader):
bag = bag.to(device)
bag_msk = bag_msk.to(device)
optimizer.zero_grad()
output = fcn_model(bag)
output = torch.sigmoid(output) # output.shape is torch.Size([4, 2, 160, 160])
loss = criterion(output, bag_msk)
iter_loss = loss.item()
all_test_iter_loss.append(iter_loss)
test_loss += iter_loss # 计算并记录误差
output_np = output.cpu().detach().numpy().copy()
output_np = np.argmax(output_np, axis=1)
bag_msk_np = bag_msk.cpu().detach().numpy().copy()
# 解释一下为什么这里的0和1一样多,因为按照onehot,这里一开始实际上每个像素点对应onehot变化是[0,1]或者[1,0],所以10的总和是一样,因为每个像素点对应了一组[1,0]
# 之后经过一个维度变换,160,160,2-->2,160,160也就是被分成了两张图片,找两个维度0所在的索引
bag_msk_np = np.argmax(bag_msk_np, axis=1)
# 计算时间
cur_time = datetime.now()
# divmod(x,y)返回一个元组,第一个参数是整除的结果,第二个是取模的结果
h, remainder = divmod((cur_time - prev_time).seconds, 3600)
m, s = divmod(remainder, 60)
time_str = "Time %02d:%02d:%02d" % (h, m, s) # 时分秒
prev_time = cur_time # 更新时间
# print()
info = 'epoch: %d, epoch train loss = %f, epoch test loss = %f, %s' \
% (epo, train_loss / len(train_dataloader), test_loss / len(test_dataloader), time_str)
print(info)
acc, acc_cls, mean_iu = label_accuracy_score(bag_msk_np, output_np, 2)
test_Acc.append(acc)
test_mIou.append(mean_iu)
print('Acc = %f, mIou = %f' % (acc, mean_iu))
# 每2个epoch存储一次模型
if np.mod(epo, 2) == 0:
# 只存储模型参数
torch.save(fcn_model.state_dict(), './pths/fcn_model_{}.pth'.format(epo))
print('成功存储模型:fcn_model_{}.pth'.format(epo))
# 绘制训练过程数据
plt.figure()
plt.subplot(221)
plt.title('train_loss')
plt.plot(all_train_iter_loss)
plt.xlabel('batch')
plt.subplot(222)
plt.title('test_loss')
plt.plot(all_test_iter_loss)
plt.xlabel('batch')
plt.subplot(223)
plt.title('test_Acc')
plt.plot(test_Acc)
plt.xlabel('epoch')
plt.subplot(224)
plt.title('test_mIou')
plt.plot(test_mIou)
plt.xlabel('epoch')
plt.show()
if __name__ == "__main__":
# 主程序
train(epo_num=20, show_vgg_params=False) # 参数是设置是否打印网络结构
参考
コードソース
FCN の詳細な説明と pytorch の簡単な実装 (詳細なコード解釈付き) - Zinc_abc のブログ - CSDN ブログ
データセットとコード