Dog Breed Identifier: Pengembangan Siklus Penuh dari Program Keras ke Aplikasi Android. di pasar bermain

Dengan kemajuan terkini dalam Neural Networks secara umum dan Pengenalan gambar khususnya, mungkin tampak bahwa membuat aplikasi berbasis NN untuk pengenalan gambar adalah operasi rutin sederhana. Ya, sampai batas tertentu memang benar: jika Anda bisa membayangkan aplikasi pengenalan gambar, maka kemungkinan besar seseorang telah melakukan hal serupa. Yang perlu Anda lakukan adalah Google mengulanginya dan mengulanginya.

Namun, masih ada detail kecil yang tak terhitung jumlahnya yang ... mereka tidak dapat dipecahkan, tidak. Mereka hanya mengambil terlalu banyak waktu Anda, terutama jika Anda seorang pemula. Apa yang bisa membantu adalah proyek selangkah demi selangkah, dilakukan tepat di depan Anda, mulai dari awal sampai akhir. Sebuah proyek yang tidak mengandung pernyataan "bagian ini jelas jadi mari kita lewati saja". Yah, hampir :)

Dalam tutorial ini kita akan berjalan melalui Dog Breed Identifier: kita akan membuat dan mengajarkan Neural Network, lalu kita akan porting ke Java untuk Android dan menerbitkannya di Google Play.

Bagi Anda yang ingin melihat hasil akhirnya, berikut ini tautan ke Aplikasi NeuroDog di Google Play.

Situs web dengan robotika saya: robotics.snowcron.com .
Situs web dengan: Panduan Pengguna NeuroDog .

Ini adalah screenshot dari program:

gambar



Ikhtisar



Kita akan menggunakan Keras: perpustakaan Google untuk bekerja dengan Neural Networks. Ini tingkat tinggi, yang berarti bahwa kurva belajar akan curam, pasti lebih cepat daripada perpustakaan lain yang saya sadari. Buat diri Anda terbiasa dengannya: ada banyak tutorial online berkualitas tinggi.

Kami akan menggunakan CNN - Jaringan Syaraf Konvolusional. CNN (dan jaringan yang lebih maju berdasarkan mereka) adalah standar de-facto dalam pengenalan gambar. Namun, mengajar satu dengan benar dapat menjadi tugas yang berat: struktur jaringan, parameter pembelajaran (semua tingkat pembelajaran, momentum, L1 dan L2 dan seterusnya) harus disesuaikan dengan hati-hati, dan karena tugas tersebut membutuhkan banyak sumber daya komputasi, kami tidak bisa begitu saja mencoba semua kombinasi yang mungkin.

Ini adalah salah satu dari beberapa alasan mengapa dalam kebanyakan kasus kami lebih suka menggunakan pendekatan "transfer pengetahuan" untuk apa yang disebut "vanilla". Transfer Knowlege menggunakan jaringan saraf yang dilatih oleh orang lain (pikirkan Google) untuk beberapa tugas lain. Lalu kita menghapus beberapa lapisan terakhir itu, tambahkan lapisan kita sendiri ... dan itu berfungsi keajaiban.

Mungkin kedengarannya aneh: kami mengambil jaringan Google yang terlatih untuk mengenali kucing, bunga, dan furnitur, dan sekarang mengidentifikasi jenis anjing! Untuk memahami cara kerjanya, mari kita lihat cara Deep Neural Networks, termasuk yang digunakan untuk pengenalan gambar, bekerja.

Kami memberinya gambar sebagai input. Lapisan pertama jaringan menganalisis gambar untuk pola-pola sederhana, seperti "garis horizontal pendek", "lengkungan", dan sebagainya. Lapisan berikutnya mengambil pola-pola ini (dan di mana mereka berada pada gambar) dan menghasilkan pola tingkat yang lebih tinggi, seperti "bulu", "sudut mata" dll. Pada akhirnya, kami memiliki puzzle yang dapat digabungkan menjadi deskripsi seekor anjing: bulu, dua mata, kaki manusia di mulut dan sebagainya.

Sekarang, semua ini dilakukan oleh serangkaian lapisan pra-pelatihan yang kami dapatkan (dari Google atau pemain besar lainnya). Akhirnya, kami menambahkan layer kami sendiri di atasnya dan kami mengajarkannya untuk bekerja dengan pola-pola itu untuk mengenali ras anjing. Kedengarannya logis.

Sebagai rangkuman, dalam tutorial ini kita akan membuat CNN “vanilla” dan beberapa jaringan “transfer learning” dari berbagai jenis. Adapun "vanilla": Saya hanya akan menggunakannya sebagai contoh bagaimana hal itu bisa dilakukan, tetapi saya tidak akan menyempurnakannya, karena jaringan "pra-terlatih" adalah cara yang lebih mudah untuk digunakan. Keras hadir dengan beberapa jaringan pra-terlatih, saya akan memilih beberapa konfigurasi dan membandingkannya.

Karena kami ingin Jaringan Saraf Tiruan kami dapat mengenali ras anjing, kami perlu "menunjukkan" sampel gambar dari berbagai ras. Untungnya, ada dataset besar yang dibuat untuk tugas serupa ( asli di sini ). Pada artikel ini, saya akan menggunakan versi dari Kaggle

Lalu saya akan port "pemenang" ke Android. Porting Keras NN ke Android relatif mudah, dan kami akan berjalan melalui semua langkah yang diperlukan.

Kemudian kami akan menerbitkannya di Google Play. Seperti yang diharapkan, Google tidak akan bekerja sama, jadi hanya sedikit trik tambahan yang diperlukan. Misalnya, Jaringan Saraf Tiruan kami melebihi ukuran yang diizinkan untuk Android APK: kami harus menggunakan bundel. Juga, Google tidak akan menampilkan aplikasi kami di hasil pencarian, kecuali kami melakukan hal-hal ajaib tertentu.

Pada akhirnya kita akan memiliki "komersial" yang berfungsi penuh (dalam tanda kutip, karena aplikasi ini gratis meskipun siap pasar) Android NN-diberdayakan.

Lingkungan pengembangan



Ada beberapa pendekatan berbeda untuk pemrograman Keras, tergantung pada OS yang Anda gunakan (Ubuntu disarankan), kartu video yang Anda miliki (atau tidak) dan sebagainya. Tidak ada yang salah dengan mengonfigurasi lingkungan pengembangan di komputer lokal Anda dan menginstal semua perpustakaan yang diperlukan dan sebagainya. Kecuali ... ada cara yang lebih mudah.

Pertama, menginstal dan mengonfigurasi beberapa alat pengembangan membutuhkan waktu dan Anda harus menghabiskan waktu lagi, ketika versi baru tersedia. Kedua, pelatihan Neural Networks membutuhkan banyak daya komputasi. Anda dapat mempercepat komputer Anda dengan menggunakan GPU ... pada saat penulisan ini, GPU teratas untuk perhitungan terkait NN menelan biaya 2.000 - 7.000 dolar. Dan mengonfigurasinya membutuhkan waktu juga.

Jadi kita akan menggunakan pendekatan yang berbeda. Lihat, Google memungkinkan orang untuk menggunakan GPU-nya secara gratis untuk perhitungan terkait NN, ia juga telah menciptakan lingkungan yang sepenuhnya terkonfigurasi; semuanya itu disebut Google Colab. Layanan ini memberi Anda akses ke Notebook Jupiter dengan Python, Keras dan banyak perpustakaan tambahan yang sudah diinstal. Yang perlu Anda lakukan adalah mendapatkan akun Google (dapatkan akun Gmail, dan Anda akan memiliki akses ke semua yang lain) dan hanya itu.

Pada saat penulisan ini, Colab dapat diakses oleh tautan ini , tetapi dapat berubah. Hanya google up "Google Colab."

Masalah nyata dengan Colab adalah layanan WEB. Bagaimana Anda akan mengakses file ANDA dari itu? Menyimpan Neural Networks setelah pelatihan selesai, memuat data khusus untuk tugas Anda dan seterusnya?

Ada beberapa (pada saat penulisan ini - tiga) pendekatan yang berbeda; kita akan menggunakan apa yang saya percaya adalah yang terbaik: menggunakan Google Drive.

Google Drive adalah penyimpanan cloud yang berfungsi hampir seperti hard drive, dan dapat dipetakan ke Google Colab (lihat kode di bawah). Kemudian Anda bekerja dengannya seperti halnya menggunakan hard drive lokal. Jadi, misalnya, jika Anda ingin mengakses foto anjing dari Neural Network yang Anda buat di Colab, Anda harus mengunggah foto-foto itu ke Google Drive Anda, itu saja.

Membuat dan melatih NN



Di bawah, saya akan berjalan melalui kode Python, satu blok kode dari Jupiter Notebook demi satu. Anda dapat menyalin kode itu ke buku catatan Anda dan menjalankannya, karena blok dapat dieksekusi terpisah satu sama lain.

Inisialisasi



Pertama-tama, mari kita pasang Google Drive. Hanya dua baris kode. Kode itu perlu dieksekusi hanya sekali per sesi Colab (katakanlah, sekali per enam jam kerja). Jika Anda menjalankannya untuk kedua kalinya, itu akan dilewati karena drive sudah terpasang.

from google.colab import drive drive.mount('/content/drive/') 


Pertama kali Anda akan diminta untuk mengkonfirmasi pemasangan - tidak ada yang rumit di sini. Ini terlihat seperti ini:

 >>> Go to this URL in a browser: ... >>> Enter your authorization code: >>> ·········· >>> Mounted at /content/drive/ 


Bagian standar yang cantik; kemungkinan besar beberapa termasuk tidak diperlukan. Juga, karena saya akan menguji konfigurasi NN yang berbeda, Anda harus mengomentari / menghapus komentar beberapa dari mereka untuk jenis NN tertentu: misalnya, untuk menggunakan InceptionV3 tipe NN, uncomment InceptionV3, dan komentar, katakanlah, ResNet50. Atau tidak: Anda dapat menyimpannya termasuk uncommented, itu akan menggunakan lebih banyak memori, tetapi hanya itu.

 import datetime as dt import pandas as pd import seaborn as sns import matplotlib.pyplot as plt from tqdm import tqdm import cv2 import numpy as np import os import sys import random import warnings from sklearn.model_selection import train_test_split import keras from keras import backend as K from keras import regularizers from keras.models import Sequential from keras.models import Model from keras.layers import Dense, Dropout, Activation from keras.layers import Flatten, Conv2D from keras.layers import MaxPooling2D from keras.layers import BatchNormalization, Input from keras.layers import Dropout, GlobalAveragePooling2D from keras.callbacks import Callback, EarlyStopping from keras.callbacks import ReduceLROnPlateau from keras.callbacks import ModelCheckpoint import shutil from keras.applications.vgg16 import preprocess_input from keras.preprocessing import image from keras.preprocessing.image import ImageDataGenerator from keras.models import load_model from keras.applications.resnet50 import ResNet50 from keras.applications.resnet50 import preprocess_input from keras.applications.resnet50 import decode_predictions from keras.applications import inception_v3 from keras.applications.inception_v3 import InceptionV3 from keras.applications.inception_v3 import preprocess_input as inception_v3_preprocessor from keras.applications.mobilenetv2 import MobileNetV2 from keras.applications.nasnet import NASNetMobile 


Di Google Drive, kita akan membuat folder untuk file kita. Baris kedua menampilkan isinya:

 working_path = "/content/drive/My Drive/DeepDogBreed/data/" !ls "/content/drive/My Drive/DeepDogBreed/data" >>> all_images labels.csv models test train valid 


Seperti yang Anda lihat, foto-foto anjing (yang disalin dari dataset Stanford (lihat di atas) ke Google Drive, awalnya disimpan di folder all_images . Nanti kita akan menyalinnya ke folder train, valid, dan test . Kita akan menyimpan model terlatih dalam folder model . Adapun file labels.csv, ini adalah bagian dari dataset, memetakan file gambar ke trah anjing.

Ada banyak tes yang dapat Anda jalankan untuk mengetahui apa yang Anda miliki, mari kita jalankan satu saja:

 # Is GPU Working? import tensorflow as tf tf.test.gpu_device_name() >>> '/device:GPU:0' 


Ok, GPU terhubung. Jika tidak, temukan di pengaturan Notebook Jupiter dan hidupkan.

Sekarang kita perlu mendeklarasikan beberapa konstanta yang akan kita gunakan, seperti ukuran gambar yang harus diharapkan oleh Neural Network dan sebagainya. Perhatikan, bahwa kami menggunakan gambar 256x256, karena ini cukup besar di satu sisi dan sesuai dengan memori di sisi lain. Namun, beberapa jenis Jaringan Saraf Tiruan yang akan kita gunakan mengharapkan gambar 224x224. Untuk menangani ini, bila perlu, komentari ukuran gambar lama dan batalkan komentar pada yang baru.

Pendekatan yang sama (komentar satu - hapus komentar yang lain) berlaku untuk nama model yang kami simpan, hanya karena kami tidak ingin menimpa hasil pengujian sebelumnya ketika kami mencoba konfigurasi baru.
 warnings.filterwarnings("ignore") os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2' np.random.seed(7) start = dt.datetime.now() BATCH_SIZE = 16 EPOCHS = 15 TESTING_SPLIT=0.3 # 70/30 % NUM_CLASSES = 120 IMAGE_SIZE = 256 #strModelFileName = "models/ResNet50.h5" # strModelFileName = "models/InceptionV3.h5" strModelFileName = "models/InceptionV3_Sgd.h5" #IMAGE_SIZE = 224 #strModelFileName = "models/MobileNetV2.h5" #IMAGE_SIZE = 224 #strModelFileName = "models/NASNetMobileSgd.h5" 


Memuat data



Pertama, mari kita memuat file labels.csv dan membagi isinya ke bagian pelatihan dan validasi. Perhatikan bahwa belum ada bagian pengujian, karena saya akan sedikit curang, untuk mendapatkan lebih banyak data untuk pelatihan.

 labels = pd.read_csv(working_path + 'labels.csv') print(labels.head()) train_ids, valid_ids = train_test_split(labels, test_size = TESTING_SPLIT) print(len(train_ids), 'train ids', len(valid_ids), 'validation ids') print('Total', len(labels), 'testing images') >>> id breed >>> 0 000bec180eb18c7604dcecc8fe0dba07 boston_bull >>> 1 001513dfcb2ffafc82cccf4d8bbaba97 dingo >>> 2 001cdf01b096e06d78e9e5112d419397 pekinese >>> 3 00214f311d5d2247d5dfe4fe24b2303d bluetick >>> 4 0021f9ceb3235effd7fcde7f7538ed62 golden_retriever >>> 7155 train ids 3067 validation ids >>> Total 10222 testing images 


Selanjutnya, kita perlu menyalin file gambar yang sebenarnya ke folder pelatihan / validasi / pengujian, sesuai dengan array nama file yang kita lewati. Fungsi berikut menyalin file dengan nama yang diberikan ke folder yang ditentukan.

 def copyFileSet(strDirFrom, strDirTo, arrFileNames): arrBreeds = np.asarray(arrFileNames['breed']) arrFileNames = np.asarray(arrFileNames['id']) if not os.path.exists(strDirTo): os.makedirs(strDirTo) for i in tqdm(range(len(arrFileNames))): strFileNameFrom = strDirFrom + arrFileNames[i] + ".jpg" strFileNameTo = strDirTo + arrBreeds[i] + "/" + arrFileNames[i] + ".jpg" if not os.path.exists(strDirTo + arrBreeds[i] + "/"): os.makedirs(strDirTo + arrBreeds[i] + "/") # As a new breed dir is created, copy 1st file # to "test" under name of that breed if not os.path.exists(working_path + "test/"): os.makedirs(working_path + "test/") strFileNameTo = working_path + "test/" + arrBreeds[i] + ".jpg" shutil.copy(strFileNameFrom, strFileNameTo) shutil.copy(strFileNameFrom, strFileNameTo) 


Seperti yang Anda lihat, kami hanya menyalin satu file untuk setiap jenis anjing ke folder tes . Saat kami menyalin file, kami juga membuat subfolder - satu subfolder untuk setiap jenis anjing. Gambar untuk setiap trah tertentu disalin ke dalam subfoldernya.

Alasannya adalah, Keras dapat bekerja dengan struktur direktori yang diatur dengan cara ini, memuat file gambar sesuai kebutuhan, menghemat memori. Ini akan menjadi ide yang sangat buruk untuk memuat semua 15.000 gambar ke dalam memori sekaligus.

Memanggil fungsi ini setiap kali kita menjalankan kode kita akan menjadi berlebihan: gambar sudah disalin, mengapa kita harus menyalinnya lagi. Jadi, beri komentar untuk pertambahan penggunaan pertama:

 # Move the data in subfolders so we can # use the Keras ImageDataGenerator. # This way we can also later use Keras # Data augmentation features. # --- Uncomment once, to copy files --- #copyFileSet(working_path + "all_images/", # working_path + "train/", train_ids) #copyFileSet(working_path + "all_images/", # working_path + "valid/", valid_ids) 


Selain itu, kami membutuhkan daftar ras anjing:

 breeds = np.unique(labels['breed']) map_characters = {} #{0:'none'} for i in range(len(breeds)): map_characters[i] = breeds[i] print("<item>" + breeds[i] + "</item>") >>> <item>affenpinscher</item> >>> <item>afghan_hound</item> >>> <item>african_hunting_dog</item> >>> <item>airedale</item> >>> <item>american_staffordshire_terrier</item> >>> <item>appenzeller</item> 


Memproses gambar



Kami akan menggunakan fitur Keras yang disebut ImageDataGenerators. ImageDataGenerator dapat memproses gambar, mengubah ukurannya, memutar, dan sebagainya. Itu juga dapat mengambil fungsi pemrosesan yang melakukan manipulasi gambar khusus.

 def preprocess(img): img = cv2.resize(img, (IMAGE_SIZE, IMAGE_SIZE), interpolation = cv2.INTER_AREA) # or use ImageDataGenerator( rescale=1./255... img_1 = image.img_to_array(img) img_1 = cv2.resize(img_1, (IMAGE_SIZE, IMAGE_SIZE), interpolation = cv2.INTER_AREA) img_1 = np.expand_dims(img_1, axis=0) / 255. #img = cv2.blur(img,(5,5)) return img_1[0] 


Perhatikan baris berikut:

 # or use ImageDataGenerator( rescale=1./255... 


Kita dapat melakukan normalisasi (menyesuaikan rentang 0-255 saluran gambar ke 0-1) di ImageDataGenerator itu sendiri. Jadi mengapa kita perlu preprosesor? Sebagai contoh, saya telah menyediakan fungsi blur (dikomentari): yaitu manipulasi gambar khusus. Anda dapat menggunakan apa saja dari mengasah ke HDR di sini.

Kami akan menggunakan dua ImageDataGenerators yang berbeda, satu untuk pelatihan dan satu untuk validasi. Perbedaannya adalah, kita perlu rotasi dan zoom untuk pelatihan, untuk membuat gambar lebih "beragam", tetapi kita tidak membutuhkannya untuk validasi (tidak dalam tugas ini).

 train_datagen = ImageDataGenerator( preprocessing_function=preprocess, #rescale=1./255, # done in preprocess() # randomly rotate images (degrees, 0 to 30) rotation_range=30, # randomly shift images horizontally # (fraction of total width) width_shift_range=0.3, height_shift_range=0.3, # randomly flip images horizontal_flip=True, ,vertical_flip=False, zoom_range=0.3) val_datagen = ImageDataGenerator( preprocessing_function=preprocess) train_gen = train_datagen.flow_from_directory( working_path + "train/", batch_size=BATCH_SIZE, target_size=(IMAGE_SIZE, IMAGE_SIZE), shuffle=True, class_mode="categorical") val_gen = val_datagen.flow_from_directory( working_path + "valid/", batch_size=BATCH_SIZE, target_size=(IMAGE_SIZE, IMAGE_SIZE), shuffle=True, class_mode="categorical") 


Menciptakan jaringan saraf



Seperti yang disebutkan di atas, kita akan membuat beberapa jenis Neural Networks. Setiap kali kami menggunakan fungsi yang berbeda, pustaka yang berbeda termasuk dan dalam beberapa kasus, ukuran gambar yang berbeda. Jadi untuk beralih dari satu jenis Neural Network ke yang lain, Anda perlu komentar / batalkan komentar terkait kode.

Pertama, mari kita buat CNN "vanilla". Ini berkinerja buruk, karena saya belum mengoptimalkannya, tetapi setidaknya itu memberikan kerangka kerja yang dapat Anda gunakan untuk membuat jaringan Anda sendiri (umumnya, itu adalah ide yang buruk, karena ada jaringan pra-terlatih yang tersedia).

 def createModelVanilla(): model = Sequential() # Note the (7, 7) here. This is one of technics # used to reduce memory use by the NN: we scan # the image in a larger steps. # Also note regularizers.l2: this technic is # used to prevent overfitting. The "0.001" here # is an empirical value and can be optimized. model.add(Conv2D(16, (7, 7), padding='same', use_bias=False, input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3), kernel_regularizer=regularizers.l2(0.001))) # Note the use of a standard CNN building blocks: # Conv2D - BatchNormalization - Activation # MaxPooling2D - Dropout # The last two are used to avoid overfitting, also, # MaxPooling2D reduces memory use. model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2), padding='same')) model.add(Dropout(0.5)) model.add(Conv2D(16, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(1, 1), padding='same')) model.add(Dropout(0.5)) model.add(Conv2D(32, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(Dropout(0.5)) model.add(Conv2D(32, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(1, 1), padding='same')) model.add(Dropout(0.5)) model.add(Conv2D(64, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(Dropout(0.5)) model.add(Conv2D(64, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(1, 1), padding='same')) model.add(Dropout(0.5)) model.add(Conv2D(128, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(Dropout(0.5)) model.add(Conv2D(128, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(1, 1), padding='same')) model.add(Dropout(0.5)) model.add(Conv2D(256, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(Dropout(0.5)) model.add(Conv2D(256, (3, 3), padding='same', use_bias=False, kernel_regularizer=regularizers.l2(0.01))) model.add(BatchNormalization(axis=3, scale=False)) model.add(Activation("relu")) model.add(MaxPooling2D(pool_size=(2, 2), strides=(1, 1), padding='same')) model.add(Dropout(0.5)) # This is the end on "convolutional" part of CNN. # Now we need to transform multidementional # data into one-dim. array for a fully-connected # classifier: model.add(Flatten()) # And two layers of classifier itself (plus an # Activation layer in between): model.add(Dense(NUM_CLASSES, activation='softmax', kernel_regularizer=regularizers.l2(0.01))) model.add(Activation("relu")) model.add(Dense(NUM_CLASSES, activation='softmax', kernel_regularizer=regularizers.l2(0.01))) # We need to compile the resulting network. # Note that there are few parameters we can # try here: the best performing one is uncommented, # the rest is commented out for your reference. #model.compile(optimizer='rmsprop', # loss='categorical_crossentropy', # metrics=['accuracy']) #model.compile( # optimizer=keras.optimizers.RMSprop(lr=0.0005), # loss='categorical_crossentropy', # metrics=['accuracy']) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) #model.compile(optimizer='adadelta', # loss='categorical_crossentropy', # metrics=['accuracy']) #opt = keras.optimizers.Adadelta(lr=1.0, # rho=0.95, epsilon=0.01, decay=0.01) #model.compile(optimizer=opt, # loss='categorical_crossentropy', # metrics=['accuracy']) #opt = keras.optimizers.RMSprop(lr=0.0005, # rho=0.9, epsilon=None, decay=0.0001) #model.compile(optimizer=opt, # loss='categorical_crossentropy', # metrics=['accuracy']) # model.summary() return(model) 


Saat kami membuat Neural Network menggunakan transfer learning , prosedurnya berubah:

 def createModelMobileNetV2(): # First, create the NN and load pre-trained # weights for it ('imagenet') # Note that we are not loading last layers of # the network (include_top=False), as we are # going to add layers of our own: base_model = MobileNetV2(weights='imagenet', include_top=False, pooling='avg', input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3)) # Then attach our layers at the end. These are # to build "classifier" that makes sense of # the patterns previous layers provide: x = base_model.output x = Dense(512)(x) x = Activation('relu')(x) x = Dropout(0.5)(x) predictions = Dense(NUM_CLASSES, activation='softmax')(x) # Create a model model = Model(inputs=base_model.input, outputs=predictions) # We need to make sure that pre-trained # layers are not changed when we train # our classifier: # Either this: #model.layers[0].trainable = False # or that: for layer in base_model.layers: layer.trainable = False # As always, there are different possible # settings, I tried few and chose the best: # model.compile(optimizer='adam', # loss='categorical_crossentropy', # metrics=['accuracy']) model.compile(optimizer='sgd', loss='categorical_crossentropy', metrics=['accuracy']) #model.summary() return(model) 


Membuat jenis NN pra-terlatih lainnya sangat mirip:

 def createModelResNet50(): base_model = ResNet50(weights='imagenet', include_top=False, pooling='avg', input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3)) x = base_model.output x = Dense(512)(x) x = Activation('relu')(x) x = Dropout(0.5)(x) predictions = Dense(NUM_CLASSES, activation='softmax')(x) model = Model(inputs=base_model.input, outputs=predictions) #model.layers[0].trainable = False # model.compile(loss='categorical_crossentropy', # optimizer='adam', metrics=['accuracy']) model.compile(optimizer='sgd', loss='categorical_crossentropy', metrics=['accuracy']) #model.summary() return(model) 


Attn: pemenang! NN ini menunjukkan hasil terbaik:

 def createModelInceptionV3(): # model.layers[0].trainable = False # model.compile(optimizer='sgd', # loss='categorical_crossentropy', # metrics=['accuracy']) base_model = InceptionV3(weights = 'imagenet', include_top = False, input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3)) x = base_model.output x = GlobalAveragePooling2D()(x) x = Dense(512, activation='relu')(x) predictions = Dense(NUM_CLASSES, activation='softmax')(x) model = Model(inputs = base_model.input, outputs = predictions) for layer in base_model.layers: layer.trainable = False # model.compile(optimizer='adam', # loss='categorical_crossentropy', # metrics=['accuracy']) model.compile(optimizer='sgd', loss='categorical_crossentropy', metrics=['accuracy']) #model.summary() return(model) 


Satu lagi:

 def createModelNASNetMobile(): # model.layers[0].trainable = False # model.compile(optimizer='sgd', # loss='categorical_crossentropy', # metrics=['accuracy']) base_model = NASNetMobile(weights = 'imagenet', include_top = False, input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3)) x = base_model.output x = GlobalAveragePooling2D()(x) x = Dense(512, activation='relu')(x) predictions = Dense(NUM_CLASSES, activation='softmax')(x) model = Model(inputs = base_model.input, outputs = predictions) for layer in base_model.layers: layer.trainable = False # model.compile(optimizer='adam', # loss='categorical_crossentropy', # metrics=['accuracy']) model.compile(optimizer='sgd', loss='categorical_crossentropy', metrics=['accuracy']) #model.summary() return(model) 


Berbagai jenis NN digunakan dalam situasi yang berbeda. Selain masalah presisi, ukuran masalah (NN seluler 5 kali lebih kecil dari Inception satu) dan kecepatan (jika kita memerlukan analisis waktu nyata dari aliran video, kita mungkin harus mengorbankan presisi).

Pelatihan jaringan saraf



Pertama-tama, kami sedang bereksperimen , jadi kami harus dapat menghapus NN yang telah kami simpan sebelumnya, tetapi tidak perlu lagi. Fungsi berikut menghapus NN jika file ada:

 # Make sure that previous "best network" is deleted. def deleteSavedNet(best_weights_filepath): if(os.path.isfile(best_weights_filepath)): os.remove(best_weights_filepath) print("deleteSavedNet():File removed") else: print("deleteSavedNet():No file to remove") 


Cara kami membuat dan menghapus NN sangat mudah. Pertama, kami hapus. Sekarang, jika Anda tidak ingin menghapus panggilan, ingat saja bahwa Jupiter Notebook memiliki fungsi "jalankan pemilihan" - pilih saja yang Anda butuhkan, dan jalankan.

Kemudian kita membuat NN jika file-nya tidak ada atau memuatnya jika file itu ada: tentu saja, kita tidak bisa memanggil "delete" dan kemudian mengharapkan NN ada, jadi untuk menggunakan jaringan yang disimpan sebelumnya, jangan panggil delete .

Dengan kata lain, kita dapat membuat NN baru atau menggunakan yang sudah ada, tergantung pada apa yang kita coba sekarang. Skenario sederhana: kami telah melatih NN, lalu pergi berlibur. Google mengeluarkan kami, jadi kami perlu memuat ulang NN: beri komentar pada bagian "hapus" dan batalkan komentar pada bagian "muat".

 deleteSavedNet(working_path + strModelFileName) #if not os.path.exists(working_path + "models"): # os.makedirs(working_path + "models") # #if not os.path.exists(working_path + # strModelFileName): # model = createModelResNet50() model = createModelInceptionV3() # model = createModelMobileNetV2() # model = createModelNASNetMobile() #else: # model = load_model(working_path + strModelFileName) 


Pos pemeriksaan sangat penting ketika mengajar NN. Anda dapat membuat berbagai fungsi untuk dipanggil di akhir setiap periode pelatihan, misalnya, Anda dapat menyimpan NN jika jika menunjukkan hasil yang lebih baik daripada yang terakhir disimpan.

 checkpoint = ModelCheckpoint(working_path + strModelFileName, monitor='val_acc', verbose=1, save_best_only=True, mode='auto', save_weights_only=False) callbacks_list = [ checkpoint ] 


Akhirnya, kami akan mengajarkan NN kami menggunakan set pelatihan:

 # Calculate sizes of training and validation sets STEP_SIZE_TRAIN=train_gen.n//train_gen.batch_size STEP_SIZE_VALID=val_gen.n//val_gen.batch_size # Set to False if we are experimenting with # some other part of code, use history that # was calculated before (and is still in # memory bDoTraining = True if bDoTraining == True: # model.fit_generator does the actual training # Note the use of generators and callbacks # that were defined earlier history = model.fit_generator(generator=train_gen, steps_per_epoch=STEP_SIZE_TRAIN, validation_data=val_gen, validation_steps=STEP_SIZE_VALID, epochs=EPOCHS, callbacks=callbacks_list) # --- After fitting, load the best model # This is important as otherwise we'll # have the LAST model loaded, not necessarily # the best one. model.load_weights(working_path + strModelFileName) # --- Presentation part # summarize history for accuracy plt.plot(history.history['acc']) plt.plot(history.history['val_acc']) plt.title('model accuracy') plt.ylabel('accuracy') plt.xlabel('epoch') plt.legend(['acc', 'val_acc'], loc='upper left') plt.show() # summarize history for loss plt.plot(history.history['loss']) plt.plot(history.history['val_loss']) plt.title('model loss') plt.ylabel('loss') plt.xlabel('epoch') plt.legend(['loss', 'val_loss'], loc='upper left') plt.show() # As grid optimization of NN would take too long, # I did just few tests with different parameters. # Below I keep results, commented out, in the same # code. As you can see, Inception shows the best # results: # Inception: # adam: val_acc 0.79393 # sgd: val_acc 0.80892 # Mobile: # adam: val_acc 0.65290 # sgd: Epoch 00015: val_acc improved from 0.67584 to 0.68469 # sgd-30 epochs: 0.68 # NASNetMobile, adam: val_acc did not improve from 0.78335 # NASNetMobile, sgd: 0.8 


Berikut adalah grafik akurasi dan kehilangan untuk pemenang NN:




Seperti yang Anda lihat, Jaringan belajar dengan baik.

Menguji Jaringan Saraf Tiruan



Setelah fase pelatihan selesai, kita perlu melakukan pengujian; untuk melakukannya, NN disajikan dengan gambar yang tidak pernah dilihatnya. Ingat, kami telah menyisihkan satu gambar untuk masing-masing spesies anjing.

 # --- Test j = 0 # Final cycle performs testing on the entire # testing set. for file_name in os.listdir( working_path + "test/"): img = image.load_img(working_path + "test/" + file_name); img_1 = image.img_to_array(img) img_1 = cv2.resize(img_1, (IMAGE_SIZE, IMAGE_SIZE), interpolation = cv2.INTER_AREA) img_1 = np.expand_dims(img_1, axis=0) / 255. y_pred = model.predict_on_batch(img_1) # get 5 best predictions y_pred_ids = y_pred[0].argsort()[-5:][::-1] print(file_name) for i in range(len(y_pred_ids)): print("\n\t" + map_characters[y_pred_ids[i]] + " (" + str(y_pred[0][y_pred_ids[i]]) + ")") print("--------------------\n") j = j + 1 


Mengekspor NN ke Jawa



Pertama, kita perlu memuat NN. Alasannya, mengekspor adalah blok kode yang terpisah, jadi kami cenderung menjalankannya secara terpisah, tanpa melatih ulang NN. Ketika Anda menggunakan kode saya, Anda tidak terlalu peduli, tetapi jika Anda melakukan pengembangan sendiri, Anda akan mencoba untuk tidak melatih kembali jaringan yang sama satu demi satu.

 # Test: load and run model = load_model(working_path + strModelFileName) 


Untuk alasan yang sama - ini entah bagaimana blok kode yang terpisah - kami menggunakan tambahan termasuk di sini. Tidak ada yang menghalangi kita untuk menaikkannya, tentu saja:

 from keras.models import Model from keras.models import load_model from keras.layers import * import os import sys import tensorflow as tf 


Sedikit pengujian, hanya untuk memastikan kami telah memuat semuanya dengan benar:

 img = image.load_img(working_path + "test/affenpinscher.jpg") #basset.jpg") img_1 = image.img_to_array(img) img_1 = cv2.resize(img_1, (IMAGE_SIZE, IMAGE_SIZE), interpolation = cv2.INTER_AREA) img_1 = np.expand_dims(img_1, axis=0) / 255. y_pred = model.predict(img_1) Y_pred_classes = np.argmax(y_pred,axis = 1) # print(y_pred) fig, ax = plt.subplots() ax.imshow(img) ax.axis('off') ax.set_title(map_characters[Y_pred_classes[0]]) plt.show() 


gambar

Selanjutnya, kita perlu mendapatkan nama lapisan input dan output jaringan kita (kecuali kita menggunakan parameter "nama" saat membuat jaringan, yang tidak kita lakukan).

 model.summary() >>> Layer (type) >>> ====================== >>> input_7 (InputLayer) >>> ______________________ >>> conv2d_283 (Conv2D) >>> ______________________ >>> ... >>> dense_14 (Dense) >>> ====================== >>> Total params: 22,913,432 >>> Trainable params: 1,110,648 >>> Non-trainable params: 21,802,784 


Kita akan menggunakan nama lapisan input dan output nanti, ketika mengimpor NN di aplikasi Android Java.

Kami juga dapat menggunakan kode berikut untuk mendapatkan info ini:

 def print_graph_nodes(filename): g = tf.GraphDef() g.ParseFromString(open(filename, 'rb').read()) print() print(filename) print("=======================INPUT===================") print([n for n in g.node if n.name.find('input') != -1]) print("=======================OUTPUT==================") print([n for n in g.node if n.name.find('output') != -1]) print("===================KERAS_LEARNING==============") print([n for n in g.node if n.name.find('keras_learning_phase') != -1]) print("===============================================") print() #def get_script_path(): # return os.path.dirname(os.path.realpath(sys.argv[0])) 


Namun, pendekatan pertama lebih disukai.

Fungsi berikut mengekspor Keras Neural Network ke format pb , yang akan kita gunakan di Android.

 def keras_to_tensorflow(keras_model, output_dir, model_name,out_prefix="output_", log_tensorboard=True): if os.path.exists(output_dir) == False: os.mkdir(output_dir) out_nodes = [] for i in range(len(keras_model.outputs)): out_nodes.append(out_prefix + str(i + 1)) tf.identity(keras_model.output[i], out_prefix + str(i + 1)) sess = K.get_session() from tensorflow.python.framework import graph_util from tensorflow.python.framework graph_io init_graph = sess.graph.as_graph_def() main_graph = graph_util.convert_variables_to_constants( sess, init_graph, out_nodes) graph_io.write_graph(main_graph, output_dir, name=model_name, as_text=False) if log_tensorboard: from tensorflow.python.tools import import_pb_to_tensorboard import_pb_to_tensorboard.import_to_tensorboard( os.path.join(output_dir, model_name), output_dir) 


Mari kita gunakan fungsi-fungsi ini untuk membuat ekspor NN:

 model = load_model(working_path + strModelFileName) keras_to_tensorflow(model, output_dir=working_path + strModelFileName, model_name=working_path + "models/dogs.pb") print_graph_nodes(working_path + "models/dogs.pb") 


Baris terakhir mencetak struktur NN kami.

Membuat Aplikasi Android yang diberdayakan NN



Mengekspor NN ke aplikasi Android. diformalkan dengan baik dan tidak menimbulkan kesulitan. Ada, seperti biasa, lebih dari satu cara melakukannya; kita akan menggunakan yang paling populer (setidaknya, saat ini).

Pertama-tama, gunakan Android Studio untuk membuat proyek baru. Kami akan memotong sudut sedikit, jadi itu hanya akan berisi satu aktivitas.

gambar

Seperti yang Anda lihat, kami telah menambahkan folder "aset" dan menyalin file Neural Network kami di sana.

File gradle



Ada beberapa perubahan yang perlu kita lakukan untuk mengatur file. Pertama-tama, kita harus mengimpor perpustakaan tensorflow-android . Ini digunakan untuk menangani Tensorflow (dan Keras, sesuai) dari Jawa:

gambar

Sebagai detail "sulit ditemukan" tambahan, catat versi: versionCode dan versionName . Saat Anda mengerjakan aplikasi, Anda perlu mengunggah versi baru ke Google Play. Tanpa memperbarui versi (seperti 1 -> 2 -> 3 ...) Anda tidak akan dapat melakukannya.

Terwujud



Pertama-tama, aplikasi kami. akan menjadi "berat" - Jaringan 100 Mb Neural mudah masuk ke memori ponsel modern, tetapi membuka contoh terpisah setiap kali pengguna "berbagi" gambar dari Facebook jelas bukan ide yang baik.

Jadi kami akan memastikan hanya ada satu instance dari aplikasi kami:

 <activity android:name=".MainActivity" android:launchMode="singleTask"> 


Dengan menambahkan android: launchMode = "singleTask" ke MainActivity, kami memberi tahu Android untuk membuka aplikasi yang sudah ada, alih-alih meluncurkan instance lain.

Maka kami memastikan aplikasi kami. muncul di daftar aplikasi yang mampu menangani gambar bersama :

 <intent-filter> <!-- Send action required to display activity in share list --> <action android:name="android.intent.action.SEND" /> <!-- Make activity default to launch --> <category android:name="android.intent.category.DEFAULT" /> <!-- Mime type ie what can be shared with this activity only image and text --> <data android:mimeType="image/*" /> </intent-filter> 


Terakhir, kita perlu meminta fitur dan izin, sehingga aplikasi dapat mengakses fungsionalitas sistem yang diperlukan:

 <uses-feature android:name="android.hardware.camera" android:required="true" /> <uses-permission android:name= "android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" tools:node="remove" /> 


Jika Anda terbiasa dengan pemrograman Android, bagian ini seharusnya tidak menimbulkan pertanyaan.

Tata Letak Aplikasi.



Kami akan membuat dua tata letak, satu untuk Potret dan satu untuk mode Lansekap. Berikut adalah tata letak Portrait .

Apa yang kita miliki di sini: tampilan besar untuk menampilkan gambar, daftar iklan yang agak mengganggu (ditampilkan ketika tombol "tulang" ditekan), tombol "Bantuan", tombol untuk memuat gambar dari File / Galeri dan dari Kamera, dan akhirnya, tombol (awalnya disembunyikan) "Proses".

gambar

Dalam aktivitas itu sendiri kita akan mengimplementasikan beberapa logika yang menunjukkan / menyembunyikan dan mengaktifkan / menonaktifkan tombol tergantung pada keadaan aplikasi.

Kegiatan utama



Aktivitas ini memperluas Aktivitas Android standar:

 public class MainActivity extends Activity 


Mari kita lihat kode yang bertanggung jawab untuk operasi NN.

Pertama-tama, NN menerima Bitmap. Awalnya ini adalah Bitmap besar dari file atau Kamera (m_bitmap), kemudian kami mengubahnya menjadi Bitmap 256x256 standar (m_bitmapForNn). Kami juga menjaga dimensi gambar (256) dalam konstan:

 static Bitmap m_bitmap = null; static Bitmap m_bitmapForNn = null; private int m_nImageSize = 256; 


Kita perlu memberi tahu NN apa nama untuk layer input dan output; jika Anda melihat daftar di atas, Anda akan menemukan bahwa namanya (dalam kasus kami! kasus Anda dapat berbeda!):

 private String INPUT_NAME = "input_7_1"; private String OUTPUT_NAME = "output_1"; 


Kemudian kita mendeklarasikan variabel untuk menahan objek TensofFlow. Kami juga menyimpan jalur ke file NN di aset:

 private TensorFlowInferenceInterface tf;
 private String MODEL_PATH = 
	 "file: ///android_asset/dogs.pb";


Trah anjing, untuk menyajikan kepada pengguna informasi yang bermakna, alih-alih indeks dalam array:
 private String[] m_arrBreedsArray; 


Awalnya, kami memuat Bitmap. Namun, NN sendiri mengharapkan susunan nilai RGB, dan outputnya adalah susunan probabilitas gambar yang disajikan sebagai jenis tertentu. Jadi kita perlu menambahkan dua array lagi (perhatikan bahwa 120 adalah jumlah breed dalam dataset pelatihan kami):

 private float[] m_arrPrediction = new float[120]; private float[] m_arrInput = null; 


Memuat perpustakaan inferensi tensorflow

 static { System.loadLibrary("tensorflow_inference"); } 


Karena operasi NN adalah operasi yang panjang, kita perlu melakukannya di utas terpisah, jika tidak ada peluang bagus untuk memukul aplikasi "sistem". tidak menanggapi "peringatan, belum lagi merusak pengalaman pengguna.

 class PredictionTask extends AsyncTask<Void, Void, Void> { @Override protected void onPreExecute() { super.onPreExecute(); } // --- @Override protected Void doInBackground(Void... params) { try { # We get RGB values packed in integers # from the Bitmap, then break those # integers into individual triplets m_arrInput = new float[ m_nImageSize * m_nImageSize * 3]; int[] intValues = new int[ m_nImageSize * m_nImageSize]; m_bitmapForNn.getPixels(intValues, 0, m_nImageSize, 0, 0, m_nImageSize, m_nImageSize); for (int i = 0; i < intValues.length; i++) { int val = intValues[i]; m_arrInput[i * 3 + 0] = ((val >> 16) & 0xFF) / 255f; m_arrInput[i * 3 + 1] = ((val >> 8) & 0xFF) / 255f; m_arrInput[i * 3 + 2] = (val & 0xFF) / 255f; } // --- tf = new TensorFlowInferenceInterface( getAssets(), MODEL_PATH); //Pass input into the tensorflow tf.feed(INPUT_NAME, m_arrInput, 1, m_nImageSize, m_nImageSize, 3); //compute predictions tf.run(new String[]{OUTPUT_NAME}, false); //copy output into PREDICTIONS array tf.fetch(OUTPUT_NAME, m_arrPrediction); } catch (Exception e) { e.getMessage(); } return null; } // --- @Override protected void onPostExecute(Void result) { super.onPostExecute(result); // --- enableControls(true); // --- tf = null; m_arrInput = null; # strResult contains 5 lines of text # with most probable dog breeds and # their probabilities m_strResult = ""; # What we do below is sorting the array # by probabilities (using map) # and getting in reverse order) the # first five entries TreeMap<Float, Integer> map = new TreeMap<Float, Integer>( Collections.reverseOrder()); for(int i = 0; i < m_arrPrediction.length; i++) map.put(m_arrPrediction[i], i); int i = 0; for (TreeMap.Entry<Float, Integer> pair : map.entrySet()) { float key = pair.getKey(); int idx = pair.getValue(); String strBreed = m_arrBreedsArray[idx]; m_strResult += strBreed + ": " + String.format("%.6f", key) + "\n"; i++; if (i > 5) break; } m_txtViewBreed.setVisibility(View.VISIBLE); m_txtViewBreed.setText(m_strResult); } } 


Di onCreate () dari MainActivity, kita perlu menambahkan onClickListener untuk tombol "Proses":

 m_btn_process.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { processImage(); } }); 


Apa yang processImage () lakukan hanyalah memanggil utas yang kami lihat di atas:

 private void processImage() { try { enableControls(false); // --- PredictionTask prediction_task = new PredictionTask(); prediction_task.execute(); } catch (Exception e) { e.printStackTrace(); } } 


Detail tambahan



Kami tidak akan membahas kode terkait UI dalam tutorial ini, karena sepele dan jelas bukan bagian dari tugas "porting NN". Namun, ada beberapa hal yang harus diklarifikasi.

Ketika kami menerapkan aplikasi kami. dari meluncurkan beberapa contoh, kami telah mencegah, pada saat yang sama, aliran normal pada kontrol: jika Anda berbagi gambar dari Facebook, dan kemudian membagikan yang lain, aplikasi tidak akan dimulai ulang. Ini berarti bahwa cara "tradisional" dalam menangani data bersama dengan menangkapnya di onCreate tidak cukup dalam kasus kami, karena onCreate tidak disebut dalam skenario yang baru kami buat.

Berikut adalah cara untuk menangani situasi:

1. Di onCreate of MainActivity, panggil fungsi onSharedIntent:

 protected void onCreate( Bundle savedInstanceState) { super.onCreate(savedInstanceState); .... onSharedIntent(); .... 


Juga, tambahkan handler untuk onNewIntent:

 @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); onSharedIntent(); } 


Fungsi onSharedIntent sendiri:
 private void onSharedIntent() { Intent receivedIntent = getIntent(); String receivedAction = receivedIntent.getAction(); String receivedType = receivedIntent.getType(); if (receivedAction.equals(Intent.ACTION_SEND)) { // If mime type is equal to image if (receivedType.startsWith("image/")) { m_txtViewBreed.setText(""); m_strResult = ""; Uri receivedUri = receivedIntent.getParcelableExtra( Intent.EXTRA_STREAM); if (receivedUri != null) { try { Bitmap bitmap = MediaStore.Images.Media.getBitmap( this.getContentResolver(), receivedUri); if(bitmap != null) { m_bitmap = bitmap; m_picView.setImageBitmap(m_bitmap); storeBitmap(); enableControls(true); } } catch (Exception e) { e.printStackTrace(); } } } } } 


Sekarang kita dapat menangani gambar bersama dari onCreate (jika aplikasi baru saja dimulai) atau dari onNewIntent jika sebuah instance ditemukan dalam memori.




Semoga beruntung Jika Anda suka artikel ini, silakan “suka” di jejaring sosial, juga ada tombol sosial di situs itu sendiri.

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


All Articles