随心情填坑。
0.背景
因为公司项目有个需求需要用到styleclip,所以就去了解了一下。这项技术就是可以通过clip输入文字,对生成的图片产生一定影响,从而生成符合描述的图片,或者,描述为图像编辑,将一张苦瓜脸变为笑脸。我这边的项目需要将该项目改为对其他目标的编辑,不是脸,但是这里就不仅仅是更改数据集这么简单了。
用到的styleclip项目:
1.stylegan
感觉stylegan应该不用我介绍,网上一搜一大堆,这里推荐看以下几篇
这两篇是一位大神写的,感觉讲解的还是非常透彻的,为方便下面的内容的理解,这里简单说一下网络结构,就不附图了。
首先是GAN网络,必然是分为两个大部分,分别呢是生成网络和判别网络,在stylegan的生成网络中,又分为两个部分,分别成为mapping和synthesis,前者是输入一段噪声生成latent,成为Z空间到W空间,后者是接收latent以及噪声,再生成图片。这里说的非常简略啊,感兴趣需要去详细了解一下,其中synthesis这个,内部有好几个block,每个block又有好几个layer,每个layer都需要接收这个latent,用意应该就是对生成图片进行干预,来生成希望的图片,而噪声就是为了对图片进行细调,比如有几根头发啊,几条皱纹啥的,增加一些波动性。个人理解。
2.styleclip
这个项目,上面第二篇已经有过介绍了,但是对于具体的方法不是特别详细,只讲了一些原理,这里增加一篇实战:
这位博主这个系列都还不错,建议都看一下,我主要是看了二和三。
对于styleclip,这篇论文一共提出了三种方法,我这边试过第一个和第三个,该项目都有提供代码,有的可能需要自己debug一下,比如第一种,optimization的方法,他默认是不使用id_loss的,但是默认又需要加载这个权重,这样的话如果没有该权重就会报错,就直接注释掉就可以了,因为默认这个loss没有使用到,代码搜索id_loss就能看到。
对于 第三种方法,是我领导最为感兴趣的方法,他觉得就是训练好stylegan之后,通过结合clip,不需要再训练啊标注啥的就能达到比较好的效果。但是这里有个问题是,项目的源码提供的是针对tf的权重来进行的,而我现有的权重是pytorch的,要怎么玩呢。
于是出现另外一个项目hyperstyle,他也提供了两种编辑latent的方法,其中一个就是global direction。说明一下,该项目默认支持的不是我发的那个stylegan的项目,但是有提供代码来转换权重,没有仔细看,应该就是对各个网络层的名字转换了一下,我用的那个是ada,而默认的是原版,大概就是这样。这里其实我已经在坑里了,自己没有想明白而已。
hyperstyle的global direction说的很明确,需要几个npy,简单说一下,需要待转换图片的latent和style,这两个npy文件,可以通过hyperstyle得到,跑一边inference就可以了,他需要stylegan的权重,据说会有点耗时;然后他还需要通过stylegan来生成两个文件,分别是s_mean_std和fs3.npy,其中后者的生成过程就需要前者,所以两个可以说一起生成的。
3.生成文件
生成文件碰到的难题是什么,hyperstyle没有提供如何生成的代码,而styleclip的项目里有,可惜是针对tf的权重的,不软那个项目在这篇文里就没有任何意义了。我这边采用的是,将tf的代码平替为pytorch的,就是tf用了啥功能,在pytorch中找替代就可以了。这里主要用到GetCode.py这个文件,修改的是他里面的函数,流程和参数主要参考上面提及的那个实战的博文。
根据操作步骤,先要生成W.npy,生成就是通过mapping部分,输入随机生成的tensor,得到W,代码如下,注释的是源代码部分,也有我改过又注释了的。
@torch.no_grad()
def GetCode(Gs,random_state,num_img,num_once,dataset_name):
rnd = np.random.RandomState(random_state) #5
truncation_psi=0.5
truncation_cutoff=8
# dlatent_avg=Gs.mapping.w_avg
dlatents=[]#np.zeros((num_img,512),dtype='float32')
for i in tqdm(range(int(num_img/num_once))):
src_latents = torch.randn([num_once, Gs.z_dim]).cuda()
src_dlatents = Gs.mapping(src_latents, None, truncation_psi=truncation_psi, truncation_cutoff=truncation_cutoff) # [seed, layer, component]
# # Apply truncation trick.
# if truncation_psi is not None and truncation_cutoff is not None:
# layer_idx = np.arange(src_dlatents.shape[1])[np.newaxis, :, np.newaxis]
# ones = np.ones(layer_idx.shape, dtype=np.float32)
# coefs = np.where(layer_idx < truncation_cutoff, truncation_psi * ones, ones)
# coefs = torch.from_numpy(coefs).cuda()
# src_dlatents_np=lerp(dlatent_avg, src_dlatents, coefs)
# src_dlatents=src_dlatents_np[:,0,:]
dlatents.append(src_dlatents[:,0,:])
dlatents = torch.cat(dlatents, dim=0)
dlatents = dlatents.cpu().numpy().astype('float32')
print('get all z and w')
tmp='./npy/'+dataset_name+'/W'
np.save(tmp,dlatents)
在tf的代码中,得到结果后还做了一个lerp这个函数的操作,不太明确是要干啥,因为看到pytorch这边好像本来就是做了这个处理,所以直接注释了。如果想要完全按照原逻辑来的话,可以打开注释,再微调一下,这里dlatent_avg这个参数,我在pytorch这边找到一个可能对应的东西,反正函数不会报错。他的结果的shape,原本应该是b*14*512(我这边只有14哈,原始脸的应该是18),然后我发现他只取了第一个,挺奇怪的,输出看了下,发现他的值,前7个和后7内容是一样的,不知道是不是做了下平均,我没有深究,即使按照他的套路来做,也是前一半和后一半是不一样的,可能后半段不需要吧。
保存完毕后,后面会需要生成一个文件S,我的代码如下
@torch.no_grad()
def GetS(dataset_name,num_img):
print('Generate S')
tmp='./npy/'+dataset_name+'/W.npy'
dlatents=np.load(tmp)[:num_img]
Gs=LoadModel(dataset_name)
# Gs.print_layers() #for ada
model=G_synthesisNetwork(Gs.synthesis)
dlatents=dlatents[:,None,:]
dlatents=np.tile(dlatents,(1,Gs.synthesis.num_ws,1))
all_s = {}
once = 20
for i in tqdm(range(num_img//once)):
src_dlatents = torch.from_numpy(dlatents[i*once:(i+1)*once]).cuda()
s = model(src_dlatents, noise_mode='const', force_fp32=True)
for k ,v in s.items():
if k not in all_s:
all_s[k] = []
all_s[k].append(v)
layer_names=[layer for layer in all_s]
all_s = [torch.cat(all_s[l], dim=0).cpu().numpy() for l in layer_names]
save_tmp=[layer_names,all_s]
return save_tmp
这里可以看到,是先要加载上面的W.npy的,然后很自然想要他是想要使用synthesis部分,这里的W.npy,他加载之后又扩展到需要的大小,那为啥之前要只取其中一个呢,不知道,可能为了节省空间吧。这里我的显卡显存有限,所以他原本是一次性得到全部的内容,我这边是用了一个循环。这边有个核心问题是,懒得贴原始代码了,简单描述就是他用tf权重,然后筛选出需要得到结果的网络层的名字,然后run一下就行了,惨的是,pytorch好像没有这个功能,踩完坑了,说一下,他需要的是每一次卷积时的style,变量名就叫style,他应该是每个layer中,会需要将输入的w经过一个仿射变换(FullyConnectedLayer)得到style,然后在处理。因为我用的那个stylegan的加载方式是下面这样的,这也是该项目自己使用的方法。
def LoadModel(model_name):
with open(model_name, 'rb') as f:
Gs = pickle.load(f)['G_ema'].cuda()
return Gs
由于懒得去改代码,找到去怎么搭建网络,再load state啥的,就沿着这个方法来往下走,但是原来的网络结构中,没有留出空间去返回我想要的值,上面代码的可以看到,我是自定义了一个类,然后输入这个Gs,类的的定义如下,这个定义主要就是复制了原始的网络的定义,然后在forward里,将self改为self.model,这样的话,原来代码里需要用到的变量会通过self.model调用到,包括网络,这么做的主要目的就是微调代码,来增加原来没有的输出。
class G_synthesisNetwork(nn.Module):
def __init__(self, model):
super(G_synthesisNetwork, self).__init__()
self.model = model
@torch.no_grad()
def forward(self, ws, **block_kwargs):
block_ws = []
with torch.autograd.profiler.record_function('split_ws'):
misc.assert_shape(ws, [None, self.model.num_ws, self.model.w_dim])
ws = ws.to(torch.float32)
w_idx = 0
for res in self.model.block_resolutions:
block = getattr(self.model, f'b{res}')
block_ws.append(ws.narrow(1, w_idx, block.num_conv + block.num_torgb))
w_idx += block.num_conv
x = img = None
outs = {}
for res, cur_ws in zip(self.model.block_resolutions, block_ws):
block = getattr(self.model, f'b{res}')
block = G_synthesisBlock(block)
x, img, style_conv0, style_conv1, style_torgb = block(x, img, cur_ws, **block_kwargs)
if style_conv0 is not None:
outs[f"synthesis.b{res}.conv0"] = style_conv0
outs[f"synthesis.b{res}.conv1"] = style_conv1
outs[f"synthesis.b{res}.torgb"] = style_torgb
return outs
原始的网络结构链接是network,上面是synthesisnetwork的类,还有block和layer都是这么操作的,就不贴了,只能说我知道这么写很low,但是这是我一下子就想到的,最省脑细胞的方法了,就是复制一下就完了。
这样的话,生成S就能够执行了,需要注意的是,这个S需要的是网络层的名字和输出,且是一一对应的,后面会用到,网络层的名字是有顺序的,最好按照conv0,conv1,torgb这样的顺序来,其中第一个block没有conv0。
生成s_mean_std文件,这个方法其实就是先生成S然后对S中的数据分别去mean和std再保存就好了,为啥不在上一步获得S的时候一起做呢,在原始代码中就是分两步,而且传递的数量也不同。简单的提一下,上面的获取S的代码中,有个变量once,是控制每个循环每次做多少个的,这里发现,在我这边,不是越大越好,设置20时的总耗时比设置为50的耗时少了好几倍。
到此,已经得到W.npy,S,s_mean_std三个文件了,按照流程,接下来需要参考SingleChannel.py这个来生成最终的fs3.npy文件了。根据上面的流程,那这个的操作就非常简单,非常类似,就不细说了,只提几个要点
首先是,代码中对于torgb层的判断是用的大写,注意修改,他的目的是区分哪些是torgb层;
然后是,tf的代码中,是说有的层需要加入噪声的,这次要改为随机造成,主要看代码的话,可以看到上面的生成用的不是随机噪声哦,在pytorch这边,因为原本就有参数控制噪声类别,所以就省略了;
最后,有个生成图片的函数中,需要传递编辑过的latent,所谓编辑就是将这个值分别乘了-5和5,具体逻辑可以看看代码,反正就是放回去的时候,主要要按照网络名来放,所以这里我又是复制了大段的网络定义,这次又修改为增加了一个传递参数,layer里面的style通过传参得到,仿射变换不用做了,大概就是下面这样
for res, cur_ws in zip(self.model.block_resolutions, block_ws):
block = getattr(self.model, f'b{res}')
block = G_synthesisBlock(block)
if f"synthesis.b{res}.conv0" in names:
style_conv0 = datas[names.index(f"synthesis.b{res}.conv0")]
else:
style_conv0 = None
style_conv1 = datas[names.index(f"synthesis.b{res}.conv1")]
style_torgb = datas[names.index(f"synthesis.b{res}.torgb")]
x, img = block(x, img, cur_ws, style_conv0, style_conv1, style_torgb, **block_kwargs)
return img
这个操作是非常耗时的,因为他是需要将latent的每个维度都要变化一下,这里摘抄一下上面提到的实战的博文的原文
论文中给出的方法是:取100个图像,对StyleGAN2输出的feature maps中的具体某一个channel,先求出其在100个图像上的平均值,保持其他channel不变,在该channel上分别加上正/负五倍均方差大小的扰动,对应生成100x2=200个图像,分别计算其在CLIP模型中的feature,算出100对图像feature的差值,并进行规格化,得到StyleGAN2输出的feature maps中的每一个channel变化时与图像CLIP语义的关联系数
这样,当输入一段有变化的CLIP文本时,首先生成图像CLIP语义的变化量,然后用这个变化量与上文提到的关联系数 Δic 相乘,得到StyleGAN2 feature maps中的每一个channel上的变化值,将该变化值与原始图片的feature maps相加,最后用预训练模型生成风格编辑后的新图像。
以上,就能够生成四个文件了,得到这四个之后,就可以在hyperstyle中使用global direction了,当然前提要先跑一下他的inference哈,得到输入图片的latent。
但是这个方法啊,我试了下脸的,用的就是官方提供的文件,然后,对于官方提供的图片,效果还是非常不错的,但是对于自己找的图片,效果比较一般,当然这个可能也是这个hyperstyle的反演功能我没有弄好,跑得次数不够或是啥吧,这个是其他同事负责的,然后用我这边自己跑的stylegan权重,不能说完全没效果,但是还是比较微妙,这个也可能是我的gan没有训练非常收敛,或者还是hyperstyle没有跑收敛啥的,又或者gan嘛,效果就那样,好的非常好,差的非常差,这也是我一直以来的观点,GAN的效果非常不稳定,也就是写写论文,真要拿来做项目,比较难以控制,最后贴几张做的脸部的global direction,第一张是官方的,后面是找的外部的图片,真是难为他了,不过对于smile,还是做到了。。。好担心这么放出会被打,求轻喷