1. 程式人生 > >掌握生成對抗網路(GANs),召喚專屬二次元老婆(老公)不是夢

掌握生成對抗網路(GANs),召喚專屬二次元老婆(老公)不是夢

全文共6706字,預計學習時長12分鐘或更長

近日,《獅子王》熱映,其逼真的外形,幾乎可以以假亂真,讓觀眾不禁大呼:awsl,這也太真實了吧!

實體模型、CGI動畫、實景拍攝、VR等技術嫻熟運用,呈現出超真實的畫面,獲得業內的一致認可。值得一提的是,影片運用人工智慧資料分析技術,將其與VR相結合,讓人們在虛擬現實的世界中體驗更加真實。

 

人工智慧領域的迅速發展和廣泛運用,不斷拉近二次元與三次元的距離,那些平面上的角色變得栩栩如生。次元壁漸漸被打破,你的紙片人老婆(老公)從螢幕裡走出來,形象更加逼真,還可私人定製哦。

 

這就需要我們理解生成對抗網路(GANs)的工作模式,並掌握如何創造和構建此類應用程式。

本文將結合生成對抗網路(GANs)的工作模式,通過相關的動漫人物資料庫去手把手教你如何建立屬於自己的動漫人物,圓夢二次元。

前提概要

這裡提到的生成對抗網路中(GAN)的深度卷積生成對抗網路(DC-GAN)不僅廣泛應用於人臉生成或者新的動漫人物,還適用於時尚風格的建立,常規內容的建立,同時也用於資料擴增的目的。

生成對抗網路很可能會改變電子遊戲和特效的生成方式。這種方法可以根據需要建立逼真的紋理或人物。

Github Repository完整程式碼傳送門:https://github.com/MLWhiz/GAN_Project

Google Colab傳送門:https://colab.research.google.com/drive/1Mxbfn0BUW4BlgEPc-minaE_M0_PaYIIX

瞭解深度卷積生成對抗網路架構

在開始編碼之前,對理論深入研究是很有幫助的。

深度卷積生成對抗網路的主要思想來自於亞歷克·雷德福,盧克·梅茲,和索米斯·錦塔勒在2016年發表的論文《Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networks》。

論文傳送門:https://arxiv.org/pdf/1511.06434.pdf

前方高能,請注意。

生成對抗網路生成偽影象的簡介

通常,生成對抗網路會使用兩個對抗的神經網路來訓練計算機去很好地掌握資料集的本質,從而生成令人信服的贗品。

大家可以把這個看作是兩個系統,其中一個利用神經網路去生成贗品(生成器),另一個是利用神經網路(鑑別器)對影象是否是贗品進行分類。 

由於生成器和鑑別器網路都在重複各自的工作,網站最終會在各自的任務下更好地工作。

把這個想象的和擊劍一樣簡單。兩個新手開始對練模式,一段時候之後,兩個人的劍術都會有所提升。

或者可以把生成器想象成一個強盜,把鑑別器想象成一個警察。經過多次盜竊之後,在一個理想的世界裡,強盜變得更擅長偷竊,而警察變得更擅長抓強盜。

這些神經網路的損失主要是由其他網路的表現決定的:

· 鑑別器網路的損耗是生成器網路質量的函式——如果鑑別器被生成器的偽影象所欺騙那它的耗損將會很高。

· 生成器網路的損耗是鑑別器網路質量的一個函式——生成器的耗損將會很高如果它無法欺騙鑑別器。

在訓練階段,技術人員會依次訓練鑑別器和生成器網路去提高鑑別器和生成器的相關效能。

目標是以權重而結束,幫助生成器生成逼真的影象。最後,人們可以利用生成神經網路從隨機噪聲中生成偽影象。

生成器架構

生成對抗網路面臨的主要問題之一是訓練的不穩定性。因此,人們不得不想出一款生成器架構來解決這個問題同時能帶來穩定的訓練。

上圖選自一篇論文,解釋了深度卷積生成對抗網路架構,可能看上去會讓人比較困惑。

本質上,可以把一個生成器神經網路想象成一個黑匣子,向它輸入一個100大小的正常生成的數字作為向量,然後它會給出一個影象:

 

如何得到這個架構?

在如下架構中,使用一個大小為4*4*1024大小的緻密層去創造100-d緻密向量。然後,用1024個過濾器將這個密集向量重塑成4x4的影象大小,如下圖所示:

 

大家現在不需要擔心任何權重的問題,因為網路本身在訓練時會掌握這些問題。

一旦有了1024張4x4的地圖,可以使用一系列的轉置卷積來進行上取樣,在每次操作之後,影象的大小翻倍並且地圖的數量減半。在最後一步中,雖然沒有將地圖的數量減半,但是需要減少到3個通道/對映只針對每個分量配置(RGB)通道,因為我們需要3個通道來輸出影象。

什麼是轉置卷積?

 

用最簡單的術語來說,轉置卷積提供了一種向上取樣影象的方法。當在卷積操作中,如果嘗試從一個4*4的影象中得到一個2*2影象,在轉置卷積中,從2*2到4*4進行卷積,如下圖所示:

那麼,卷積神經網路(CNN)中,上池化(Un-pooling)在輸入特徵圖進行向上取樣中的應用日趨廣泛。為什麼不使用上池化呢?

這是因為上池化不涉及任何學習。然而,轉置卷積是可學習的,這就是為什麼更推薦轉置卷積而不是上池化的原因。它們的引數可以通過生成器學習。

鑑別器架構

現在,對於生成器的架構已經有所瞭解,而鑑別器就像一個黑匣子。

在實際應用中,在最後,它包含一系列的卷積層和一個稠密層,用來預測影象是否為偽影象,如下圖所示:

將影象作為輸入,並且預測它是否是真的/假的。每一個影象都永遠可以進行卷積操作。

資料預處理和視覺化

第一件事就是檢視資料集中的一些影象。下面是一些語言指令用於視覺化資料集中的一些影象:

filenames = glob.glob('animeface-character-dataset/*/*.pn*')
plt.figure(figsize=(10, 8))
for i in range(5):   
 img = plt.imread(filenames[i], 0) 
   plt.subplot(4, 5, i+1)  
  plt.imshow(img)   
 plt.title(img.shape) 
   plt.xticks([])  
  plt.yticks([])
plt.tight_layout()
plt.show()

相關結果輸出如下:

大家可以清楚看到影象的尺寸和影象本身。

在繼續接下來的訓練之前,在這種特別的情況下,需要將影象預處理為64*64*3的標準大小。

在使用它去訓練生成對抗網路之前,規範影象的畫素也是需要的一個步驟。可以看到這個程式碼,它的註解十分詳細。

 # A function to normalize image pixels.

def norm_img(img): 
   '''A function to Normalize Images.  
  Input:   
     img : Original image as numpy array.  
  Output: Normailized Image as numpy array  
  '''    
img = (img / 127.5) - 1   
 return img
def denorm_img(img):   
 '''A function to Denormailze, i.e. recreate image from normalized image   
 Input:     
   img : Normalized image as numpy array.   
 Output: Original Image as numpy array  
  '''   
 img = (img + 1) * 127.5 
  return img.astype(np.uint8)
def sample_from_dataset(batch_size, image_shape, data_dir=None):   
 '''Create a batch of image samples by sampling random images from a data 
directory.   
 Resizes the image using image_shape and normalize the images.   
 Input:    
    batch_size : Sample size required      
  image_size : Size that Image should be resized to    
    data_dir : Path of directory where training images are placed.   
 Output:     
   sample : batch of processed images    
'''  
  sample_dim = (batch_size,) + image_shape   
 sample = np.empty(sample_dim, dtype=np.float32) 
   all_data_dirlist = list(glob.glob(data_dir))  
  sample_imgs_paths = np.random.choice(all_data_dirlist,batch_size) 
   for index,img_filename in enumerate(sample_imgs_paths):     
   image = Image.open(img_filename)     
   image = image.resize(image_shape[:-1])     
   image = image.convert('RGB')       
 image = np.asarray(image)    
    image = norm_img(image)    
    sample[index,...] = image  
  return sample

先前的定義函式將會在程式碼的訓練部分中被使用。

實現深度卷積生成對抗網路(DCGAN)

這部分是關於定義深度卷積生成對抗網路的,將定義人們的噪聲發生器功能,生成器架構和鑑別器架構。

為生成器生成噪聲向量

下面的程式碼塊是一個為生成器建立一個預定義長度的有用函式。它通過使用生成器架構將產生人們想要轉換為影象的噪音。使用一個正態分佈去生成噪音向量:

def gen_noise(batch_size, noise_shape):   
 ''' Generates a numpy vector sampled from normal distribution of shape   
                             (batch_size,noise_shape)  
  Input:      
  batch_size : size of batch  
      noise_shape: shape of noise vector, normally kept as 100   
 Output:a numpy vector sampled from normal distribution of shape  
                                (batch_size,noise_shape)    

     '''return np.random.normal(0, 1, size=(batch_size,)+noise_shape)

 

生成器架構

生成器是生成對抗網路中的關鍵部分。

通過新增一些轉置卷積層來建立一個生成器,以便對影象中的噪聲向量進行上取樣。

這個生成器架構與原始的深度卷積生成對抗網路論文中給出的並不相同。

需要做一些架構上的改變來更好地擬合數據,所以在中間添加了一個卷積層,並從生成器架構中清除了所有的密集層,使它達到完全卷積的效果。

筆者還使用了許多動量為0.5的Batch norm層並激活ReLU漏洞。同時使用β= 0.5的亞當優化器。下面的程式碼塊是用來建立生成器的函式:

 

def get_gen_normal(noise_shape): 
    ''' This function takes as input shape of the noise vector and creates 
the Keras generator    architecture.  
    '''   
 kernel_init = 'glorot_uniform'    
    gen_input = Input(shape = noise_shape)    

    # Transpose 2D conv layer 1.  
    generator = Conv2DTranspose(filters = 512, kernel_size = (4,4), strides = 
(1,1), padding = "valid", data_format = "channels_last", kernel_initializer = 
kernel_init)(gen_input)  
    generator = BatchNormalization(momentum = 0.5)(generator)   
    generator = LeakyReLU(0.2)(generator)    
  
    # Transpose 2D conv layer 2.    
    generator = Conv2DTranspose(filters = 256, kernel_size = (4,4), strides = 
(2,2), padding = "same", data_format = "channels_last", kernel_initializer = 
kernel_init)(generator)
    generator = BatchNormalization(momentum = 0.5)(generator)
    generator = LeakyReLU(0.2)(generator)

        # Transpose 2D conv layer 3.
    generator = Conv2DTranspose(filters = 128, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(generator)
    generator = BatchNormalization(momentum = 0.5)(generator)
    generator = LeakyReLU(0.2)(generator)

        # Transpose 2D conv layer 4.
    generator = Conv2DTranspose(filters = 64, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(generator)
    generator = BatchNormalization(momentum = 0.5)(generator)
    generator = LeakyReLU(0.2)(generator)

        # conv 2D layer 1.    
        generator = Conv2D(filters = 64, kernel_size = (3,3), strides = (1,1),
 padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(generator)    generator = BatchNormalization(momentum = 0.5)(generator)
    generator = LeakyReLU(0.2)(generator)

        # Final Transpose 2D conv layer 5 to generate final image. Filter size 3
 for 3 image channel
    generator = Conv2DTranspose(filters = 3, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(generator)

        # Tanh activation to get final normalized image
    generator = Activation('tanh')(generator)

        # defining the optimizer and compiling the generator model.
    gen_opt = Adam(lr=0.00015, beta_1=0.5)
    generator_model = Model(input = gen_input, output = generator)
    generator_model.compile(loss='binary_crossentropy', optimizer=gen_opt,
 metrics=['accuracy'])
    generator_model.summary()
    return generator_model

繪製出最終生成器模型:

 plot_model(generator, to_file='gen_plot.png', show_shapes=True,

show_layer_names=True)

 

鑑別器架構

最後,在鑑別器架構中使用一系列卷積層和一層緻密層用來預測圖片是否有虛假或不存的現象。

 

def get_disc_normal(image_shape=(64,64,3)):
    dropout_prob = 0.4
    kernel_init = 'glorot_uniform'
    dis_input = Input(shape = image_shape)

        # Conv layer 1:
    discriminator = Conv2D(filters = 64, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(dis_input)
    discriminator = LeakyReLU(0.2)(discriminator)
    # Conv layer 2:
    discriminator = Conv2D(filters = 128, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(discriminator)
    discriminator = BatchNormalization(momentum = 0.5)(discriminator)
    discriminator = LeakyReLU(0.2)(discriminator)
    # Conv layer 3:
    discriminator = Conv2D(filters = 256, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(discriminator)
    discriminator = BatchNormalization(momentum = 0.5)(discriminator)
    discriminator = LeakyReLU(0.2)(discriminator)
    # Conv layer 4:
    discriminator = Conv2D(filters = 512, kernel_size = (4,4), strides =
 (2,2), padding = "same", data_format = "channels_last", kernel_initializer =
 kernel_init)(discriminator)
    discriminator = BatchNormalization(momentum =0.5)(discriminator)
    discriminator = LeakyReLU(0.2)(discriminator)#discriminator = 
MaxPooling2D(pool_size=(2, 2))(discriminator)
    # Flatten
    discriminator = Flatten()(discriminator)
    # Dense Layer
    discriminator = Dense(1)(discriminator)
    # Sigmoid Activation
    discriminator = Activation('sigmoid')(discriminator)
    # Optimizer and Compiling model
    dis_opt = Adam(lr=0.0002, beta_1=0.5)
    discriminator_model = Model(input = dis_input, output = discriminator)
    discriminator_model.compile(loss='binary_crossentropy',
 optimizer=dis_opt, metrics=['accuracy'])
    discriminator_model.summary()
    return discriminator_model

這是鑑別器的架構:

plot_model(discriminator, to_file='dis_plot.png', show_shapes=True,
 show_layer_names=True)

鑑別器架構

 

訓練階段

理解在生成對抗網路中訓練的運作過程是極其重要的。當然也有可能很有趣。

通過使用之前章節中的函式定義開始創造鑑別器和發生器:

 

discriminator = get_disc_normal(image_shape)generator = 
get_gen_normal(noise_shape)

發生器和鑑別器隨之結合起來創造最終的生成對抗網路。

discriminator.trainable = False
# Optimizer for the GANopt = Adam(lr=0.00015, beta_1=0.5) #same as generator
# Input to the generatorgen_inp = Input(shape=noise_shape)

GAN_inp = generator(gen_inp)
GAN_opt = discriminator(GAN_inp)

# Final GAN
gan = Model(input = gen_inp, output = GAN_opt)
gan.compile(loss = 'binary_crossentropy', optimizer = opt, metrics=
['accuracy'])

plot_model(gan, to_file='gan_plot.png', show_shapes=True,
 show_layer_names=True)

這是整個生成對抗網路的的架構:

訓練迴圈

這是需要大家明白目前創造的區塊如何集合並共同運作成一體的主要區域。

 

# Use a fixed noise vector to see how the GAN Images transition through time
on a fixed noise.
fixed_noise = gen_noise(16,noise_shape)

 # To keep Track of losses
avg_disc_fake_loss = []
avg_disc_real_loss = []
avg_GAN_loss = []

 # We will run for num_steps iterations
for step in range(num_steps):
    tot_step = step
    print("Begin step: ", tot_step)
    # to keep track of time per step
    step_begin_time = time.time()

        # sample a batch of normalized images from the dataset
    real_data_X = sample_from_dataset(batch_size, image_shape, data_dir=data_dir)

        # Genearate noise to send as input to the generator
    noise = gen_noise(batch_size,noise_shape)

        # Use generator to create(predict) images 
   fake_data_X = generator.predict(noise)

        # Save predicted images from the generator every 10th step
    if (tot_step % 100) == 0:
        step_num = str(tot_step).zfill(4)
        save_img_batch(fake_data_X,img_save_dir+step_num+"_image.png")

        # Create the labels for real and fake data. We don't give exact ones and
 zeros but add a small amount of noise. This is an important GAN training
 trick
    real_data_Y = np.ones(batch_size) - np.random.random_sample(batch_size)*0.2
    fake_data_Y = np.random.random_sample(batch_size)*0.2

        # train the discriminator using data and labels

     discriminator.trainable = True
    generator.trainable = False

     # Training Discriminator seperately on real data 
   dis_metrics_real = discriminator.train_on_batch(real_data_X,real_data_Y)
    # training Discriminator seperately on fake data
    dis_metrics_fake = discriminator.train_on_batch(fake_data_X,fake_data_Y)

        print("Disc: real loss: %f fake loss: %f" % (dis_metrics_real[0],
dis_metrics_fake[0]))

        # Save the losses to plot later
    avg_disc_fake_loss.append(dis_metrics_fake[0])
    avg_disc_real_loss.append(dis_metrics_real[0])

        # Train the generator using a random vector of noise and its labels (1's
 with noise)
    generator.trainable = True
    discriminator.trainable = False 

    GAN_X = gen_noise(batch_size,noise_shape)
    GAN_Y = real_data_Y

       gan_metrics = gan.train_on_batch(GAN_X,GAN_Y)
    print("GAN loss: %f" % (gan_metrics[0]))

        # Log results by opening a file in append mode
    text_file = open(log_dir+"\\training_log.txt", "a")
    text_file.write("Step: %d Disc: real loss: %f fake loss: %f GAN loss:
 %f\n" % (tot_step, dis_metrics_real[0], dis_metrics_fake[0],gan_metrics[0]))
    text_file.close()

     # save GAN loss to plot later
    avg_GAN_loss.append(gan_metrics[0])

                end_time = time.time()
    diff_time = int(end_time - step_begin_time)
    print("Step %d completed. Time took: %s secs." % (tot_step, diff_time))

        # save model at every 500 steps
    if ((tot_step+1) % 500) == 0:
        print("--------------------------------------------------------------
---")
        print("Average Disc_fake loss: %f" % (np.mean(avg_disc_fake_loss)))
        print("Average Disc_real loss: %f" % (np.mean(avg_disc_real_loss)))
        print("Average GAN loss: %f" % (np.mean(avg_GAN_loss)))
        print("--------------------------------------------------------------
---")        
        discriminator.trainable = False        
        generator.trainable = False
        # predict on fixed_noise
        fixed_noise_generate = generator.predict(noise)
        step_num = str(tot_step).zfill(4)
        save_img_batch(fixed_noise_generate,img_save_dir+step_num+"fixed_imag
e.png")
        generator.save(save_model_dir+str(tot_step)+"_GENERATOR_weigh
ts_and_arch.hdf5")
        discriminator.save(save_model_dir+str(tot_step)+"_DISCRIMINATOR_weigh
ts_and_arch.hdf5")

不必擔心,接下來會盡量將以上程式碼一步步分解開來。在每一個訓練迭代中的主要步驟有:

第一步:從資料集目錄中採集一批規範化的影象樣本。

 

# Use a fixed noise vector to see how the GAN Images transition through time
 on a fixed noise.
fixed_noise = gen_noise(16,noise_shape)

# To keep Track of losses
avg_disc_fake_loss= []
avg_disc_real_loss = []
avg_GAN_loss = []

# We will run for num_steps iterations
for step in range(num_steps):
    tot_step = step
    print("Begin step: ", tot_step)
    # to keep track of time per step
    step_begin_time = time.time()

    # sample a batch of normalized images from the dataset
    real_data_X = sample_from_dataset(batch_size, image_shape,
 data_dir=data_dir)

第二步:生成噪聲以輸入到發生器中。

 

# Generate noise to send as input to the generator
    noise = gen_noise(batch_size,noise_shape)

第三步:通過使用在使用生成器時的隨機噪音生成影象。

 # Use generator to create(predict) images

fake_data_X = generator.predict(noise)

        # Save predicted images from the generator every 100th step
    if (tot_step % 100) == 0:
        step_num = str(tot_step).zfill(4)

save_img_batch(fake_data_X,img_save_dir+step_num+"_image.png")

第四步:使用生成器影象(偽影象)和真正歸一化處理的影象(真實影象)以及其噪聲標籤訓練鑑別器。

 

# Create the labels for real and fake data. We don't give exact ones and
 zeros but add a small amount of noise. This is an important GAN training
 trick
    real_data_Y = np.ones(batch_size) - 
np.random.random_sample(batch_size)*0.2
    fake_data_Y = np.random.random_sample(batch_size)*0.2

            # train the discriminator using data and labels

discriminator.trainable = True
    generator.trainable = False

# Training Discriminator seperately on real data
    dis_metrics_real = discriminator.train_on_batch(real_data_X,real_data_Y)

     # training Discriminator seperately on fake data    dis_metrics_fake =
 discriminator.train_on_batch(fake_data_X,fake_data_Y)
         print("Disc: real loss: %f fake loss: %f" % (dis_metrics_real[0],
 dis_metrics_fake[0]))
        # Save the losses to plot later
    avg_disc_fake_loss.append(dis_metrics_fake[0])
    avg_disc_real_loss.append(dis_metrics_real[0])

第五步:在保持鑑別器不可訓練的狀態下以噪聲為X,以1’s(噪聲的)為Y,訓練生成對抗網路。

  

# Train the generator using a random vector of noise and its labels (1's with
 noise)   
    generator.trainable = True    
    discriminator.trainable = False

GAN_X = gen_noise(batch_size,noise_shape)
    GAN_Y = real_data_Y

    gan_metrics = gan.train_on_batch(GAN_X,GAN_Y)
    print("GAN loss: %f" % (gan_metrics[0]))

通過迴圈重複步驟直至獲得優秀的鑑別器和生成器。

成果顯示

最終輸出影象即如下所示。正如所見,生成對抗網路能為內容編輯的朋友們生成非常棒的影象。

對個人愛好來說這些影象也許略顯粗糙,但對研究生成對抗網路來說,這個專案是個開始。

 

訓練期間的損失

這是關於損失的圖表。如圖所示,生成對抗網路的損失在一個平均下降的趨勢,並且隨著步驟的增加,方差也在下降。為了得到更好的結果甚至可能需要更多的迭代訓練。

 

每1500步生成的影象

在Colab中可以見到輸出和運作中的程式碼:

 

# Generating GIF from PNGs
import imageio
# create a list of PNGs
generated_images = [img_save_dir+str(x).zfill(4)+"_image.png" for x in
range(0,num_steps,100)]
images = []
for filename in generated_images:
    images.append(imageio.imread(filename))
imageio.mimsave(img_save_dir+'movie.gif', images)
from IPython.display import Image
with open(img_save_dir+'movie.gif','rb') as f:
    display(Image(data=f.read(), format='png'))

以下給出的程式碼是在不同訓練步驟中用來生成一些影象的程式碼。正如所見,隨著步驟數量的增加,影象質量也越來越好。

 

# create a list of 20 PNGs to show
generated_images = [img_save_dir+str(x).zfill(4)+"fixed_image.png" for x in
 range(0,num_steps,1500)]
print("Displaying generated images")
# You might need to change grid size and figure size here according to num 
images.plt.figure(figsize=(16,20))
gs1 = gridspec.GridSpec(5, 4)
gs1.update(wspace=0, hspace=0)for i,image in enumerate(generated_images):
    ax1 = plt.subplot(gs1[i])
    ax1.set_aspect('equal')
    step = image.split("fixed")[0]
    image = Image.open(image)
    fig = plt.imshow(image)
    # you might need to change some params here
    fig = plt.text(20,47,"Step: "+step,bbox=dict(facecolor='red',
 alpha=0.5),fontsize=12)
    plt.axis('off')
    fig.axes.get_xaxis().set_visible(False)
    fig.axes.get_yaxis().set_visible(False)
plt.tight_layout()
plt.savefig("GENERATEDimage.png",bbox_inches='tight',pad_inches=0)
plt.show()

以下給出的是不同時間步驟中生成對抗網路的結果:

深度卷積層生成對抗網路不僅適用於生成人臉或新的動漫人物,還可以用作生成新的時尚風格,用於普通內容建立,有時也用於資料擴張。

如果大家手中握有訓練資料,現在就可以行動起來,按需求創造並召喚自己的二次元老婆(老公)啦!

 

留言 點贊 關注

我們一起分享AI學習與發展的乾貨
歡迎關注全平臺AI垂類自媒體 “讀芯術”

(新增小編微信:dxsxbb,加入讀者圈,一起討論最新鮮的人工智慧科技哦