Membuat Seni Menggunakan DCGAN di Keras

Hari yang baik Enam bulan yang lalu, saya mulai mempelajari pembelajaran mesin, menjalani beberapa kursus dan mendapatkan beberapa pengalaman dengan ini. Kemudian, melihat semua jenis berita tentang apa jaringan saraf itu keren dan dapat melakukan banyak hal, saya memutuskan untuk mencoba mempelajarinya. Saya mulai membaca buku Nikolenko tentang pembelajaran mendalam dan selama membaca saya punya beberapa ide (yang bukan hal baru bagi dunia, tetapi sangat menarik bagi saya), salah satunya adalah menciptakan jaringan saraf yang akan menghasilkan seni untuk saya yang sepertinya keren bukan hanya untuk saya, "ayah dari anak gambar", tetapi juga untuk orang lain. Pada artikel ini saya akan mencoba menggambarkan jalan yang saya lalui untuk mendapatkan hasil pertama yang memuaskan saya.


Pengumpulan Data


Ketika saya membaca bab tentang jaringan kompetitif, saya menyadari bahwa sekarang saya dapat menulis sesuatu.
Salah satu tugas pertama adalah menulis parser halaman web untuk mengumpulkan dataset. Untuk ini, situs web wikiart sempurna , ia memiliki sejumlah besar lukisan dan semua dikumpulkan berdasarkan gaya. Ini adalah parser pertama saya, jadi saya menulisnya selama 4-5 hari, 3 yang pertama mengambil poke di sepanjang jalan yang benar-benar salah. Cara yang benar adalah pergi ke tab jaringan di kode sumber halaman dan melacak bagaimana gambar muncul ketika Anda mengklik tombol "lainnya". Sebenarnya, untuk pemula yang sama seperti saya, akan baik untuk menunjukkan kodenya.


from scipy.misc import imresize, imsave from matplotlib.image import imread import requests import json from bs4 import BeautifulSoup from itertools import count import os import glob 

Di sel jupiter pertama, saya mengimpor perpustakaan yang diperlukan.


  • glob - Hal praktis untuk mendapatkan daftar file di direktori
  • permintaan, BeautifulSoup - Kumis untuk parsing
  • json - perpustakaan untuk mendapatkan kamus yang kembali ketika Anda mengklik tombol "lainnya" di situs
  • mengubah ukuran, menyimpan, menambah - untuk membaca gambar dan mempersiapkannya.

 def get_page(style, pagenum): page = requests.get(url1 + style + url2 + str(pagenum) + url3) return page def make_soup(page): soup = BeautifulSoup(page.text, 'html5lib') return soup def make_dir(name, s): path = os.getcwd() + '/' + s + '/' + name os.mkdir(path) 

Saya menjelaskan fungsi untuk operasi yang mudah.


Yang pertama - mendapatkan halaman dalam bentuk teks, yang kedua membuat teks ini lebih nyaman untuk digunakan. Nah, yang ketiga adalah membuat folder yang diperlukan berdasarkan gaya.


 styles = ['kubizm'] url1 = 'https://www.wikiart.org/ru/paintings-by-style/' url2 = '?select=featured&json=2&layout=new&page=' url3 = '&resultType=masonry' 

Dalam array gaya, berdasarkan desain, seharusnya ada beberapa gaya, tetapi kebetulan saya mengunduhnya dengan tidak merata.


 for style in styles: make_dir(style, 'images') for style in styles: make_dir(style, 'new256_images') 

Buat folder yang diperlukan. Siklus kedua membuat folder tempat gambar akan disimpan, diratakan menjadi 256x256 persegi.


(Awalnya saya berpikir entah bagaimana tidak menormalkan ukuran gambar sehingga tidak ada distorsi, tetapi saya menyadari bahwa ini tidak mungkin atau terlalu sulit bagi saya)


 for style in styles: path = os.getcwd() + '\\images\\' + style + '\\' images = [] names = [] titles = [] for pagenum in count(start=1): page = get_page(style, pagenum) if page.text[0]!='{': break jsons = json.loads(page.text) paintings = jsons['Paintings'] if paintings is None: break for item in paintings: images_temp = [] images_dict = item['images'] if images_dict is None: images_temp.append(item['image']) names.append(item['artistName']) titles.append(item['title']) else: for inner_item in item['images']: images_temp.append(inner_item['image']) names.append(item['artistName']) titles.append(item['title']) images.append(images_temp) for char in ['/','\\','"', '?', ':','*','|','<','>']: titles = [title.replace(char, ' ') for title in titles] for listimg, name, title in zip(images, names, titles): if len(name) > 30: name = name[:25] if len(title) > 50: title = title[:50] if len(listimg) == 1: response = requests.get(listimg[0]) if response.status_code == 200: with open(path + name + ' ' + title + '.png', 'wb') as f: f.write(response.content) else: print('Error from server') else: for i, img in enumerate(listimg): response = requests.get(img) if response.status_code == 200: with open(path + name + ' ' + title + str(i) + '.png', 'wb') as f: f.write(response.content) else: print('Error from server') 

Di sini gambar diunduh dan disimpan ke folder yang diinginkan. Di sini gambar tidak berubah ukuran, aslinya disimpan.


Hal-hal menarik terjadi di loop bersarang pertama:


Saya memutuskan untuk terus-menerus bertanya kepada json (json adalah kamus yang dikembalikan server ketika Anda mengklik tombol "Lainnya". Kamus berisi semua informasi tentang gambar), dan berhenti ketika server mengembalikan sesuatu yang cadel dan tidak seperti nilai-nilai khas . Dalam hal ini, karakter pertama dari teks yang dikembalikan seharusnya adalah braket keriting pembuka, setelah itu datang tubuh kamus.


Juga telah diperhatikan bahwa server dapat mengembalikan sesuatu seperti album gambar. Itu pada dasarnya adalah serangkaian lukisan. Pada awalnya saya berpikir bahwa satu lukisan kembali, nama para seniman untuk mereka, atau mungkin sehingga sekaligus dengan satu nama seniman serangkaian lukisan diberikan.


  for style in styles: directory = os.getcwd() + '\\images\\' + style + '\\' new_dir = os.getcwd() + '\\new256_images\\' + style + '\\' filepaths = [] for dir_, _, files in os.walk(directory): for fileName in files: #relDir = os.path.relpath(dir_, directory) #relFile = os.path.join(relDir, fileName) relFile = fileName #print(directory) #print(relFile) filepaths.append(relFile) #print(filepaths[-1]) print(filepaths[0]) for i, fp in enumerate(filepaths): img = imread(directory + fp, 0) #/ 255.0 img = imresize(img, (256, 256)) imsave(new_dir + str(i) + ".png", img) 

Di sini gambar diubah ukurannya dan disimpan dalam folder yang disiapkan untuk mereka.


Nah, dataset sudah terpasang, Anda bisa melanjutkan ke yang paling menarik!


Mulai dari yang kecil



Selanjutnya, setelah membaca artikel asli, saya mulai membuat! Tapi apa kekecewaan saya ketika tidak ada yang baik keluar. Dalam upaya ini, saya melatih jaringan pada gaya gambar yang sama, tetapi bahkan itu tidak berhasil, jadi saya memutuskan untuk mulai belajar cara menghasilkan angka dari pengganda. Saya tidak akan tinggal di sini secara rinci, saya hanya akan berbicara tentang arsitektur dan titik kritis, berkat angka-angka yang mulai dihasilkan.


 def build_generator(): model = Sequential() model.add(Dense(128 * 7 * 7, input_dim = latent_dim)) model.add(BatchNormalization()) model.add(LeakyReLU()) model.add(Reshape((7, 7, 128))) model.add(Conv2DTranspose(64, filter_size, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(32, filter_size, strides=(1, 1), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(img_channels, filter_size, strides=(2,2), padding='same')) model.add(Activation("tanh")) model.summary() return model 

  • latent_dim - larik 100 angka yang dibuat secara acak.


     def build_discriminator(): model = Sequential() model.add(Conv2D(64, kernel_size=filter_size, strides = (2,2), input_shape=img_shape, padding="same")) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(128, kernel_size=filter_size, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(128, kernel_size=filter_size, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Flatten()) model.add(Dense(1)) model.add(Activation('sigmoid')) model.summary() return model 

    Yaitu, secara total, ukuran keluaran dari lapisan konvolusional dan jumlah lapisan umumnya kurang dari pada artikel asli. 28x28 karena saya menghasilkan, bukan interior!



Nah, trik yang membuat semuanya berjalan baik - pada iterasi pelatihan yang rata, diskriminator melihat gambar yang dihasilkan, dan pada iterasi yang aneh - pada yang asli.


Itu pada dasarnya itu. DCGAN serupa belajar dengan sangat cepat, misalnya, gambar di awal subtopik ini diperoleh pada era ke-19,



Ini sudah percaya diri, tetapi kadang-kadang bukan angka nyata, mereka ternyata di era pendidikan ke-99.


Puas dengan hasil awal, saya berhenti belajar dan mulai berpikir tentang bagaimana menyelesaikan masalah utama.


Jaringan permusuhan kreatif


Langkah selanjutnya adalah membaca tentang GAN dengan label: kelas gambar saat ini disajikan ke pembeda dan generator. Dan setelah gan dengan label, saya tahu tentang CAN - decoding pada dasarnya adalah atas nama subtopik.


Dalam BISA, pembeda mencoba menebak kelas gambar jika gambar itu dari set nyata. Dan, dengan demikian, dalam hal pelatihan dalam gambaran nyata, pembeda, selain default, menerima kesalahan dari menebak kelas sebagai kesalahan.


Saat melatih gambar yang dihasilkan, pembeda hanya perlu memperkirakan apakah gambar ini nyata atau tidak.


Generator, di samping itu, hanya untuk mengelabui diskriminator, perlu membuat diskriminator bingung ketika menebak kelas gambar, yaitu, generator akan tertarik pada kenyataan bahwa output ke diskriminator sejauh mungkin dari 1, kepercayaan penuh.


Beralih ke CAN, saya kembali mengalami kesulitan, demoralitas karena kenyataan bahwa tidak ada yang berhasil dan tidak belajar. Setelah beberapa kegagalan yang tidak menyenangkan, saya memutuskan untuk memulai dari awal lagi dan menyimpan semua perubahan (Ya, saya tidak melakukan ini sebelumnya), bobot dan arsitektur (untuk menghentikan pelatihan).


Pertama, saya ingin membuat jaringan yang akan menghasilkan gambar 256x256 tunggal untuk saya (Semua gambar berikut dengan ukuran ini) tanpa label. Titik balik di sini adalah bahwa, sebaliknya, dalam setiap iterasi pelatihan, pembeda harus melihat gambar yang dihasilkan dan yang asli.



Ini adalah hasil yang saya berhenti dan pindah ke langkah berikutnya. Ya, warnanya berbeda dari gambar aslinya, tetapi saya lebih tertarik pada kemampuan jaringan untuk menyorot kontur dan objek. Dia mengatasi ini.


Kemudian kita bisa melanjutkan ke tugas utama - menghasilkan seni. Segera tunjukkan kode tersebut, beri komentar di sepanjang jalan.


Pertama, seperti biasa, Anda perlu mengimpor semua perpustakaan.


 import glob from PIL import Image from keras.preprocessing.image import array_to_img, img_to_array, load_img from datetime import date from datetime import datetime import tensorflow as tf import numpy as np import argparse import math import os from matplotlib.image import imread from scipy.misc.pilutil import imresize, imsave import matplotlib.pyplot as plt import cv2 import keras from keras.models import Sequential, Model from keras.layers import Dense, Activation, Reshape, Flatten, Dropout, Input from keras.layers.convolutional import Conv2D, Conv2DTranspose, MaxPooling2D from keras.layers.normalization import BatchNormalization from keras.layers.advanced_activations import LeakyReLU from keras.optimizers import Adam, SGD from keras.datasets import mnist from keras import initializers import numpy as np import random 

Membuat generator.


Output dari layer sekali lagi berbeda dari artikel. Di suatu tempat untuk menghemat memori (Kondisi: komputer di rumah dengan gtx970), dan di suatu tempat karena sukses dengan konfigurasi


 def build_generator(): model = Sequential() model.add(Dense(128 * 16 * 8, input_dim = latent_dim)) model.add(BatchNormalization()) model.add(LeakyReLU()) model.add(Reshape((8, 8, 256))) model.add(Conv2DTranspose(512, filter_size_g, strides=(1,1), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(512, filter_size_g, strides=(1,1), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(256, filter_size_g, strides=(1,1), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(128, filter_size_g, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(64, filter_size_g, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(32, filter_size_g, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(16, filter_size_g, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(8, filter_size_g, strides=(2,2), padding='same')) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU()) model.add(Conv2DTranspose(img_channels, filter_size_g, strides=(1,1), padding='same')) model.add(Activation("tanh")) model.summary() return model 

Fungsi pembuatan diskriminator mengembalikan dua model, satu di antaranya mencoba mencari tahu apakah gambar itu nyata, dan yang lain mencoba mencari tahu kelas gambar tersebut.


 def build_discriminator(num_classes): model = Sequential() model.add(Conv2D(64, kernel_size=filter_size_d, strides = (2,2), input_shape=img_shape, padding="same")) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(128, kernel_size=filter_size_d, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(256, kernel_size=filter_size_d, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Conv2D(512, kernel_size=filter_size_d, strides = (2,2), padding="same")) model.add(BatchNormalization(momentum=0.8)) model.add(LeakyReLU(alpha=0.2)) model.add(Dropout(0.25)) model.add(Flatten()) model.summary() img = Input(shape=img_shape) features = model(img) validity = Dense(1)(features) valid = Activation('sigmoid')(validity) label1 = Dense(1024)(features) lrelu1 = LeakyReLU(alpha=0.2)(label1) label2 = Dense(512)(label1) lrelu2 = LeakyReLU(alpha=0.2)(label2) label3 = Dense(num_classes)(label2) label = Activation('softmax')(label3) return Model(img, valid), Model(img, label) 

Berfungsi untuk membuat model kompetitif. Dalam model kompetitif, pembeda tidak dilatih.


 def generator_containing_discriminator(g, d, d_label): noise = Input(shape=(latent_dim,)) img = g(noise) d.trainable = False d_label.trainable = False valid, target_label = d(img), d_label(img) return Model(noise, [valid, target_label]) 

Fungsi untuk mengunduh kumpulan dengan gambar dan label nyata. data - array alamat yang akan ditentukan kemudian. Dalam fungsi yang sama, gambar dinormalisasi.


 def get_images_classes(batch_size, data): X_train = np.zeros((batch_size, img_rows, img_cols, img_channels)) y_labels = np.zeros(batch_size) choice_arr = np.random.randint(0, len(data), batch_size) for i in range(batch_size): rand_number = np.random.randint(0, len(data[choice_arr[i]])) temp_img = cv2.imread(data[choice_arr[i]][rand_number]) X_train[i] = temp_img y_labels[i] = choice_arr[i] X_train = (X_train - 127.5)/127.5 return X_train, y_labels 

Berfungsi untuk hasil yang indah dari kumpulan gambar. Sebenarnya, semua gambar dari artikel ini dikumpulkan oleh fungsi ini.


 def combine_images(generated_images): num = generated_images.shape[0] width = int(math.sqrt(num)) height = int(math.ceil(float(num)/width)) shape = generated_images.shape[1:3] image = np.zeros((height*shape[0], width*shape[1], img_channels), dtype=generated_images.dtype) for index, img in enumerate(generated_images): i = int(index/width) j = index % width image[i*shape[0]:(i+1)*shape[0], j*shape[1]:(j+1)*shape[1]] = \ img[:, :, :,] return image 

Dan ini data yang sama. Ini dalam bentuk yang kurang lebih nyaman mengembalikan satu set alamat gambar, yang kita, di atas, disusun dalam folder


 def get_data(): styles_folder = os.listdir(path=os.getcwd() + "\\new256_images\\") num_styles = len(styles_folder) data = [] for i in range(num_styles): data.append(glob.glob(os.getcwd() + '\\new256_images\\' + styles_folder[i] + '\\*')) return data, num_styles 

Untuk melewati era, sejumlah besar acak ditetapkan, karena terlalu malas untuk menghitung jumlah semua gambar. Dalam fungsi yang sama memuat skala disediakan jika perlu untuk melanjutkan pelatihan. Setiap 5 era, berat, dan arsitektur dipertahankan.


Penting juga menulis bahwa saya mencoba menambahkan noise ke gambar input, tetapi pada pelatihan terakhir saya memutuskan untuk tidak melakukan ini.
Label kelas smoothed digunakan, mereka sangat membantu belajar.


 def train_another(epochs = 100, BATCH_SIZE = 4, weights = False, month_day = '', epoch = ''): data, num_styles = get_data() generator = build_generator() discriminator, d_label = build_discriminator(num_styles) discriminator.compile(loss=losses[0], optimizer=d_optim) d_label.compile(loss=losses[1], optimizer=d_optim) generator.compile(loss='binary_crossentropy', optimizer=g_optim) if month_day != '': generator.load_weights(os.getcwd() + '/' + month_day + epoch + ' gen_weights.h5') discriminator.load_weights(os.getcwd() + '/' + month_day + epoch + ' dis_weights.h5') d_label.load_weights(os.getcwd() + '/' + month_day + epoch + ' dis_label_weights.h5') dcgan = generator_containing_discriminator(generator, discriminator, d_label) dcgan.compile(loss=losses[0], optimizer=g_optim) discriminator.trainable = True d_label.trainable = True for epoch in range(epochs): for index in range(int(15000/BATCH_SIZE)): noise = np.random.normal(0, 1, (BATCH_SIZE, latent_dim)) real_images, real_labels = get_images_classes(BATCH_SIZE, data) #real_images += np.random.normal(size = img_shape, scale= 0.1) generated_images = generator.predict(noise) X = real_images real_labels = real_labels - 0.1 + np.random.rand(BATCH_SIZE)*0.2 y_classif = keras.utils.to_categorical(np.zeros(BATCH_SIZE) + real_labels, num_styles) y = 0.8 + np.random.rand(BATCH_SIZE)*0.2 d_loss = [] d_loss.append(discriminator.train_on_batch(X, y)) discriminator.trainable = False d_loss.append(d_label.train_on_batch(X, y_classif)) print("epoch %d batch %d d_loss : %f, label_loss: %f" % (epoch, index, d_loss[0], d_loss[1])) X = generated_images y = np.random.rand(BATCH_SIZE) * 0.2 d_loss = discriminator.train_on_batch(X, y) print("epoch %d batch %d d_loss : %f" % (epoch, index, d_loss)) noise = np.random.normal(0, 1, (BATCH_SIZE, latent_dim)) discriminator.trainable = False d_label.trainable = False y_classif = keras.utils.to_categorical(np.zeros(BATCH_SIZE) + 1/num_styles, num_styles) y = np.random.rand(BATCH_SIZE) * 0.3 g_loss = dcgan.train_on_batch(noise, [y, y_classif]) d_label.trainable = True discriminator.trainable = True print("epoch %d batch %d g_loss : %f, label_loss: %f" % (epoch, index, g_loss[0], g_loss[1])) if index % 50 == 0: image = combine_images(generated_images) image = image*127.5+127.5 cv2.imwrite( os.getcwd() + '\\generated\\epoch%d_%d.png' % (epoch, index), image) image = combine_images(real_images) image = image*127.5+127.5 cv2.imwrite( os.getcwd() + '\\generated\\epoch%d_%d_data.png' % (epoch, index), image) if epoch % 5 == 0: date_today = date.today() month, day = date_today.month, date_today.day #      json d_json = discriminator.to_json() #     json_file = open(os.getcwd() + "/%d.%d dis_model.json" % (day, month), "w") json_file.write(d_json) json_file.close() #      json d_l_json = d_label.to_json() #     json_file = open(os.getcwd() + "/%d.%d dis_label_model.json" % (day, month), "w") json_file.write(d_l_json) json_file.close() #      json gen_json = generator.to_json() #     json_file = open(os.getcwd() + "/%d.%d gen_model.json" % (day, month), "w") json_file.write(gen_json) json_file.close() discriminator.save_weights(os.getcwd() + '/%d.%d %d_epoch dis_weights.h5' % (day, month, epoch)) d_label.save_weights(os.getcwd() + '/%d.%d %d_epoch dis_label_weights.h5' % (day, month, epoch)) generator.save_weights(os.getcwd() + '/%d.%d %d_epoch gen_weights.h5' % (day, month, epoch)) 

Inisialisasi variabel dan jalankan pelatihan. Karena "daya" komputer saya rendah, pelatihan dimungkinkan pada maksimum 16 gambar.


 img_rows = 256 img_cols = 256 img_channels = 3 img_shape = (img_rows, img_cols, img_channels) latent_dim = 100 filter_size_g = (5,5) filter_size_d = (5,5) d_strides = (2,2) color_mode = 'rgb' losses = ['binary_crossentropy', 'categorical_crossentropy'] g_optim = Adam(0.0002, beta_2 = 0.5) d_optim = Adam(0.0002, beta_2 = 0.5) train_another(1000, 16) 

Secara umum, untuk waktu yang lama saya ingin menulis posting tentang habr tentang ide saya ini, sekarang bukan waktu terbaik untuk ini, karena neuron ini telah belajar selama tiga hari dan sekarang di era ke-113, tetapi hari ini saya menemukan gambar yang menarik, jadi saya memutuskan bahwa sudah waktunya. sudah menulis posting!



Ini adalah gambar yang keluar hari ini. Mungkin dengan menyebutkannya, saya dapat menyampaikan kepada pembaca persepsi pribadi saya tentang gambar-gambar ini. Sangat jelas bahwa jaringannya tidak cukup terlatih (atau mungkin tidak dilatih dengan metode seperti itu sama sekali), terutama mengingat bahwa foto-foto itu diambil oleh juru tulis, tetapi hari ini saya mendapatkan hasil yang saya sukai.


Rencana masa depan termasuk melatih kembali konfigurasi ini sampai menjadi jelas apa yang mampu dilakukannya. Juga direncanakan untuk membuat jaringan yang akan memperbesar gambar-gambar ini ke ukuran yang waras. Ini sudah ditemukan dan ada implementasi.


Saya akan sangat senang atas kritik yang membangun, saran dan pertanyaan yang bagus.

Source: https://habr.com/ru/post/id431614/


All Articles