15
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

皆さんこんにちは。@best_not_bestです。
この記事は初めてのDeep Learning 〜奮闘編〜の続きになります。未読の方は是非そちらを先にお読みください。
ほぼ1年越しの投稿になってすいませんでした・・・。

注意

本記事は個人的欲望から生み出されたものであり、所属する組織の公式見解ではございません。

結論から言うと

  • 似た人を探せました(`・ω・´)
  • Chainerではなくて、似たようなサンプルプログラムがあったTensorFlowを使うことにしました(´・ω・`)
  • Python 2.7 → Python 3.5にしたので全部作り直しました(´;ω;`)

環境

  • マシン/OS
    • MacBook Pro (Retina, 15-inch, Mid 2014)
    • OS X Yosemite 10.10.5
  • Python
    • Python 3.5.2 :: Anaconda 4.1.1 (x86_64)
  • Pythonパッケージ
    • lxml 3.6.0
    • selenium 3.0.2
    • numpy 1.11.2
    • opencv3 3.1.0(condaでインストールします。)
    • tensorflow 0.10.0

※Python 3.xでOpenCVを使うためにAnacondaで実行しています。

改めて手順

1. 社員の画像を集める
2. 1.の画像の顔部分を切り抜く
3. 学習用画像(好きな芸能人)を集める
4. 3.の顔部分を切り抜く
5. 学習用画像(好きな芸能人以外を適当に)を集める
6. 5.の顔部分を切り抜く
7. Tensorflowで4.と6.を学習させ、判別器を作成
8. 2.の画像を判別器で判別させる

1. 社員の画像を集める

処理の詳細は準備編を参照ください。

1.1. 社員IDの取得

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import lxml.html
from selenium import webdriver
import os

target_url = 'http://hogehoge.co.jp/list.html'
driver = webdriver.PhantomJS(service_log_path = os.path.devnull)
driver.get(target_url)
root = lxml.html.fromstring(driver.page_source)
links = root.cssselect('td.text12m')
for link in links:
    if link.text is None:
        continue
    if link.text.isdigit():
        print(link.text)

driver.close()
driver.quit()

標準出力に社員IDが出力されますので、ファイル等にリダイレクションしてください。
以降、このファイルをmember_id.txtとして扱います。

1.2. 社員IDから画像URLを生成し、取得

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import os
import urllib.request
import urllib.parse
import time

# 上記社員IDファイル
ID_LIST = '/path/to/member_id.txt'
# 社員画像URLフォーマット
URL_FMT = 'http://hogehoge.co.jp/%s.jpg'
# ファイル保存先パスフォーマット
OUTPUT_FMT = '/path/to/photo/%s.jpg'

opener = urllib.request.build_opener()
urllib.request.install_opener(opener)

for id in open(ID_LIST, 'r'):
    url = URL_FMT % (id.strip())

    try:
        img = urllib.request.urlopen(url, timeout=5).read()
        if len(img) == 0:
            continue

    except urllib.request.URLError:
        print(url, 'URLError')

    except IOError:
        print(url, 'IOError')

    except UnicodeEncodeError:
        print(url, 'EncodeError')

    except OSError:
        print(url, 'OSError')

    else:
        output = OUTPUT_FMT % id.strip()
        file = open(output, 'wb')
        file.write(img)
        file.close()

    time.sleep(0.1)

以下のようなファイル名で保存されます。

000001.jpg
000002.jpg
000003.jpg
000004.jpg
000005.jpg
...

2. 1.の画像の顔部分を切り抜く

処理の詳細は準備編を参照ください。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import numpy
import os
import sys
import cv2

# OpenCVのパッケージ内にある定義ファイルを指定
CASCADE_PATH = '/path/to/versions/anaconda3-4.1.1/pkgs/opencv3-3.1.0-py35_0/share/OpenCV/haarcascades/haarcascade_frontalface_alt.xml'
# 1.で保存したディレクトリ
INPUT_DIR_PATH = '/path/to/photos/'
# 切り抜いた画像の保存ディレクトリ
OUTPUT_DIR_PATH = '/path/to/cutout/'
# 画像ファイル名フォーマット
# 1枚の画像から複数枚切り抜くことがあるため、連番を付ける
OUTPUT_FILE_FMT = '%s%s_%d%s'
COLOR = (255, 255, 255)

files = os.listdir(INPUT_DIR_PATH)
for file in files:
    input_image_path = INPUT_DIR_PATH + file

    # ファイル読み込み
    image = cv2.imread(input_image_path)
    # グレースケール変換
    try:
        image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    except cv2.error:
        continue

    # カスケード分類器の特徴量を取得する
    cascade = cv2.CascadeClassifier(CASCADE_PATH)

    # 物体認識(顔認識)の実行
    facerect = cascade.detectMultiScale(image_gray, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))

    if len(facerect) > 0:
        # 認識結果の保存
        i = 1
        for rect in facerect:
            x = rect[0]
            y = rect[1]
            w = rect[2]
            h = rect[3]

            path, ext = os.path.splitext(os.path.basename(file))
            output_image_path = OUTPUT_FILE_FMT % (OUTPUT_DIR_PATH, path, i, ext)
            try:
                im = cv2.resize(image[y:y+h, x:x+w], (96, 96))
                cv2.imwrite(output_image_path, im)
            except cv2.error:
                print(file)
                continue

            i += 1

以下のようなファイル名で保存されます。

000001_1.jpg
000002_1.jpg
000003_1.jpg
000003_2.jpg
000004_1.jpg
...

3. 学習用画像(好きな芸能人)を集める

処理の詳細は奮闘編を参照ください。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import sys
import os
import json
import urllib.request
import urllib.parse
import requests
import mimetypes
import re

# API URL
BING_URL = 'https://api.datamarket.azure.com/Bing/Search/Image?'
# API ACCESS KEY
MS_ACCTKEY = 'hogehoge'
QUERY = '好きな芸能人の名前'
# 取得した画像の保存ディレクトリ
OUTPUT_DIR_PATH = '/path/to/talent/'

opener = urllib.request.build_opener()
urllib.request.install_opener(opener)

def download_urllist(urllist, skip):
    for url in urllist:
        try:
            img = urllib.request.urlopen(url, timeout=5).read()
            if len(img) == 0:
                continue

            url = re.sub(r'\?.*', '', url)
            mine_type = mimetypes.guess_type(url)[0]
            if mine_type is None:
                mine_type = 'jpeg'
            else:
                mine_type = mine_type.split('/')[1]

            file_name = '%s.%s' % (skip, mine_type)
            with open(OUTPUT_DIR_PATH + file_name, 'wb') as f:
                f.write(img)

        except urllib.request.URLError:
            print('URLError')

        except IOError:
            print('IOError')

        except UnicodeEncodeError:
            print('EncodeError')

        except OSError:
            print('OSError')

        skip += 1

if __name__ == "__main__":
    query = urllib.request.quote(QUERY)
    step = 20
    num = 50

    url_param_dict = {
        'Query': "'"+QUERY+"'",
        'Market': "'ja-JP'",
    }
    url_param_base = urllib.parse.urlencode(url_param_dict)
    url_param_base = url_param_base + '&$format=json&$top=%d&$skip='%(num)

    for skip in range(0, num*step, num):
        url_param = url_param_base + str(skip)
        url = BING_URL + url_param

        response = requests.get(url,
                                auth=(MS_ACCTKEY, MS_ACCTKEY),
                                headers={'User-Agent': 'My API Robot'})
        response = response.json()

        urllist = [item['MediaUrl'] for item in response['d']['results']]
        download_urllist(urllist, skip)

4. 3.の顔部分を切り抜く

処理の詳細は奮闘編を参照ください。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import numpy
import os
import sys
import cv2

# OpenCVのパッケージ内にある定義ファイルを指定
CASCADE_PATH = '/path/to/versions/anaconda3-4.1.1/pkgs/opencv3-3.1.0-py35_0/share/OpenCV/haarcascades/haarcascade_frontalface_alt.xml'
# 3.で保存したディレクトリ
INPUT_DIR_PATH = '/path/to/talent/'
# 切り抜いた画像の保存ディレクトリ
OUTPUT_DIR_PATH = '/path/to/talent_cutout/'
# 画像ファイル名フォーマット
# 1枚の画像から複数枚切り抜くことがあるため、連番を付ける
OUTPUT_FILE_FMT = '%s%s_%d%s'
COLOR = (255, 255, 255)

files = os.listdir(INPUT_DIR_PATH)
for file in files:
    input_image_path = INPUT_DIR_PATH + file

    # ファイル読み込み
    image = cv2.imread(input_image_path)
    # グレースケール変換
    try:
        image_gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    except cv2.error:
        continue

    # カスケード分類器の特徴量を取得する
    cascade = cv2.CascadeClassifier(CASCADE_PATH)

    # 物体認識(顔認識)の実行
    facerect = cascade.detectMultiScale(image_gray, scaleFactor=1.1, minNeighbors=1, minSize=(1, 1))

    if len(facerect) > 0:
        # 認識結果の保存
        i = 1
        for rect in facerect:
            x = rect[0]
            y = rect[1]
            w = rect[2]
            h = rect[3]

            path, ext = os.path.splitext(os.path.basename(file))
            output_image_path = OUTPUT_FILE_FMT % (OUTPUT_DIR_PATH, count, i, ext)
            try:
                im = cv2.resize(image[y:y+h, x:x+w], (96, 96))
                cv2.imwrite(output_image_path, im)
            except cv2.error:
                print(file)
                continue

            i += 1

以下のようなファイル名で保存されます。

7_3.jpeg
6_1.jpeg
4_1.jpeg
3_1.jpeg
2_1.jpeg
...

5. 学習用画像(好きな芸能人以外を適当に)を集める

3.のプログラムのQUERYOUTPUT_DIR_PATHを変更して動かします。
今回は「一般人」というQUERYで実行してみました。

QUERY = '一般人'
OUTPUT_DIR_PATH = '/path/to/other_talent/'

6. 5.の顔部分を切り抜く

4.と同様の処理のため割愛させていただきます。

7. Tensorflowで4.と6.を学習させ、判別器を作成

データセットを作成します。
好きな芸能人の画像ファイルに「1」のラベルを付け、ランダムソートします。

$ ls -la /path/to/talent_cutout/*.* | awk '{print $9" 1"}' | gsort -R > talent.txt

8割を学習データ、2割をテストデータに分けます。(以下はファイル総数が941だったため、752と189に分けています。)

$ head -752 talent.txt > talent_train.txt
$ tail -189 talent.txt > talent_test.txt

同様に好きな芸能人以外の画像も「2」のラベルを付け、学習データ(commons_train.txt)とテストデータ(commons_test.txt)に分けます。
それぞれの学習データ、テストデータを結合し、ランダムソートします。

$ cat commons_train.txt talent_train.txt | gsort -R > train.txt
$ cat commons_test.txt talent_test.txt | gsort -R > test.txt

ファイルの中身は以下のようになります。

$ head -5 train.txt
/path/to/other_talent_cutout/152_16.jpeg 2
/path/to/talent_cutout/371_1.jpg 1
/path/to/talent_cutout/349_1.jpg 1
/path/to/talent_cutout/523_2.jpg 1
/path/to/other_talent_cutout/348_2.jpeg 2

Tensorflowに学習させます。
TensorFlow 大量の画像から学習するには・・・〜(ほぼ)解決編〜 - Qiita を参考にさせていただきました。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import sys
import cv2
import numpy as np
import tensorflow as tf
import tensorflow.python.platform

NUM_CLASSES = 3
IMAGE_SIZE = 28
IMAGE_PIXELS = IMAGE_SIZE * IMAGE_SIZE * 3

flags = tf.app.flags
FLAGS = flags.FLAGS
# 学習結果を保存するファイルのパス
flags.DEFINE_string('save_model', '/path/to/model.ckpt', 'File name of model data')
# 学習データのパス
flags.DEFINE_string('train', '/path/to/train.txt', 'File name of train data.')
# テストデータのパス
flags.DEFINE_string('test', '/path/to/test.txt', 'File name of test data.')
flags.DEFINE_string('train_dir', './log_data', 'Directory to put the training data.')
flags.DEFINE_integer('max_steps', 100, 'Number of steps to run trainer.')
flags.DEFINE_integer(
    'batch_size',
    10,
    'Batch size'
    'Must divide evenly into the dataset sizes.'
)
flags.DEFINE_float('learning_rate', 1e-4, 'Initial learning rate.')

def inference(images_placeholder, keep_prob):
    def weight_variable(shape):
        initial = tf.truncated_normal(shape, stddev=0.1)
        return tf.Variable(initial)

    def bias_variable(shape):
        initial = tf.constant(0.1, shape=shape)
        return tf.Variable(initial)

    def conv2d(x, W):
        return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')

    def max_pool_2x2(x):
        return tf.nn.max_pool(
            x,
            ksize=[1, 2, 2, 1],
            strides=[1, 2, 2, 1],
            padding='SAME'
        )

    x_images = tf.reshape(images_placeholder, [-1, IMAGE_SIZE, IMAGE_SIZE, 3])

    with tf.name_scope('conv1') as scope:
        W_conv1 = weight_variable([5, 5, 3, 32])
        b_conv1 = bias_variable([32])
        h_conv1 = tf.nn.relu(conv2d(x_images, W_conv1) + b_conv1)

    with tf.name_scope('pool1') as scope:
        h_pool1 = max_pool_2x2(h_conv1)

    with tf.name_scope('conv2') as scope:
        W_conv2 = weight_variable([5, 5, 32, 64])
        b_conv2 = bias_variable([64])
        h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)

    with tf.name_scope('pool2') as scope:
        h_pool2 = max_pool_2x2(h_conv2)

    with tf.name_scope('fc1') as scope:
        W_fc1 = weight_variable([7 * 7 * 64, 1024])
        b_fc1 = bias_variable([1024])
        h_pool2_flat = tf.reshape(h_pool2, [-1, 7 * 7 * 64])
        h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)
        h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

    with tf.name_scope('fc2') as scope:
        W_fc2 = weight_variable([1024, NUM_CLASSES])
        b_fc2 = bias_variable([NUM_CLASSES])

    with tf.name_scope('softmax') as scope:
        y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)
    return y_conv

def loss(logits, labels):
    cross_entropy = -tf.reduce_sum(labels*tf.log(tf.clip_by_value(logits, 1e-10, 1.0)))
    tf.scalar_summary('cross_entropy', cross_entropy)
    return cross_entropy

def training(loss, learning_rate):
    train_step = tf.train.AdamOptimizer(learning_rate).minimize(loss)
    return train_step

def accuracy(logits, labels):
    correct_prediction = tf.equal(tf.argmax(logits, 1), tf.argmax(labels, 1))
    accuracy = tf.reduce_mean(tf.cast(correct_prediction, 'float'))
    tf.scalar_summary('accuracy', accuracy)
    return accuracy

if __name__ == '__main__':
    with open(FLAGS.train, 'r') as f: # train.txt
        train_image = []
        train_label = []
        for line in f:
            line = line.rstrip()
            l = line.split()
            img = cv2.imread(l[0])
            img = cv2.resize(img, (IMAGE_SIZE, IMAGE_SIZE))
            train_image.append(img.flatten().astype(np.float32) / 255.0)
            tmp = np.zeros(NUM_CLASSES)
            tmp[int(l[1])] = 1
            train_label.append(tmp)
        train_image = np.asarray(train_image)
        train_label = np.asarray(train_label)
        train_len = len(train_image)

    with open(FLAGS.test, 'r') as f:
        test_image = []
        test_label = []
        for line in f:
            line = line.rstrip()
            l = line.split()
            img = cv2.imread(l[0])
            img = cv2.resize(img, (IMAGE_SIZE, IMAGE_SIZE))
            test_image.append(img.flatten().astype(np.float32) / 255.0)
            tmp = np.zeros(NUM_CLASSES)
            tmp[int(l[1])] = 1
            test_label.append(tmp)
        test_image = np.asarray(test_image)
        test_label = np.asarray(test_label)
        test_len = len(test_image)

    with tf.Graph().as_default():
        images_placeholder = tf.placeholder('float', shape=(None, IMAGE_PIXELS))
        labels_placeholder = tf.placeholder('float', shape=(None, NUM_CLASSES))
        keep_prob = tf.placeholder('float')

        logits = inference(images_placeholder, keep_prob)
        loss_value = loss(logits, labels_placeholder)
        train_op = training(loss_value, FLAGS.learning_rate)
        acc = accuracy(logits, labels_placeholder)

        saver = tf.train.Saver()
        sess = tf.Session()
        sess.run(tf.initialize_all_variables())
        summary_op = tf.merge_all_summaries()
        summary_writer = tf.train.SummaryWriter(FLAGS.train_dir, sess.graph_def)

        if train_len % FLAGS.batch_size is 0:
            train_batch = train_len / FLAGS.batch_size
        else:
            train_batch = (train_len / FLAGS.batch_size) + 1
            print('train_batch = ' + str(train_batch))
        for step in range(FLAGS.max_steps):
            for i in range(int(train_batch)):
                batch = FLAGS.batch_size * i
                batch_plus = FLAGS.batch_size * (i + 1)
                if batch_plus > train_len:
                    batch_plus = train_len

                sess.run(train_op, feed_dict={
                    images_placeholder: train_image[batch: batch_plus],
                    labels_placeholder: train_label[batch: batch_plus],
                    keep_prob: 0.5
                })

            if step % 10 == 0:
                train_accuracy = 0.0
                for i in range(int(train_batch)):
                    batch = FLAGS.batch_size * i
                    batch_plus = FLAGS.batch_size * (i + 1)
                    if batch_plus > train_len: batch_plus = train_len
                    train_accuracy += sess.run(acc, feed_dict={
                        images_placeholder: train_image[batch: batch_plus],
                        labels_placeholder: train_label[batch: batch_plus],
                        keep_prob: 1.0})
                    if i is not 0: train_accuracy /= 2.0

                print('step %d, training accuracy %g' % (step, train_accuracy))

    if test_len % FLAGS.batch_size is 0:
        test_batch = test_len / FLAGS.batch_size
    else:
        test_batch = (test_len / FLAGS.batch_size) + 1
        print('test_batch = ' + str(test_batch))

    test_accuracy = 0.0
    for i in range(int(test_batch)):
        batch = FLAGS.batch_size * i
        batch_plus = FLAGS.batch_size * (i + 1)
        if batch_plus > train_len:
            batch_plus = train_len
        test_accuracy += sess.run(
            acc,
            feed_dict={
                images_placeholder: test_image[batch:batch_plus],
                labels_placeholder: test_label[batch:batch_plus],
                keep_prob: 1.0
            }
        )
        if i is not 0:
            test_accuracy /= 2.0

    print('test accuracy %g' % (test_accuracy))
    save_path = saver.save(sess, FLAGS.save_model)

/path/to/model.ckptに学習結果が保存されます。

8. 2.の画像を判別器で判別させる

こちらもTensorFlow 大量の画像から学習するには・・・〜(ほぼ)解決編〜 - Qiita を参考にさせていただきました。

#!/usr/bin/env python
# -*- coding: UTF-8 -*-

import os
import sys
import numpy as np
import tensorflow as tf
import cv2
import tensorflow.python.platform
from types import *

NUM_CLASSES = 3
IMAGE_SIZE = 28
IMAGE_PIXELS = IMAGE_SIZE * IMAGE_SIZE * 3
# 2.で切り抜いた画像の保存ディレクトリ
DIR_PATH = '/path/to/cutout/'

flags = tf.app.flags
FLAGS = flags.FLAGS
flags.DEFINE_string('readmodels', '/path/to/model.ckpt', 'File name of model data')

def inference(images_placeholder, keep_prob):
    def weight_variable(shape):
        initial = tf.truncated_normal(shape, stddev=0.1)
        return tf.Variable(initial)

    def bias_variable(shape):
        initial = tf.constant(0.1, shape=shape)
        return tf.Variable(initial)

    def conv2d(x, W):
        return tf.nn.conv2d(x, W, strides=[1, 1, 1, 1], padding='SAME')

    def max_pool_2x2(x):
        return tf.nn.max_pool(
            x,
            ksize=[1, 2, 2, 1],
            strides=[1, 2, 2, 1],
            padding='SAME'
        )

    x_image = tf.reshape(images_placeholder, [-1, IMAGE_SIZE, IMAGE_SIZE, 3])

    with tf.name_scope('conv1') as scope:
        W_conv1 = weight_variable([5, 5, 3, 32])
        b_conv1 = bias_variable([32])
        h_conv1 = tf.nn.relu(conv2d(x_image, W_conv1) + b_conv1)

    with tf.name_scope('pool1') as scope:
        h_pool1 = max_pool_2x2(h_conv1)

    with tf.name_scope('conv2') as scope:
        W_conv2 = weight_variable([5, 5, 32, 64])
        b_conv2 = bias_variable([64])
        h_conv2 = tf.nn.relu(conv2d(h_pool1, W_conv2) + b_conv2)

    with tf.name_scope('pool2') as scope:
        h_pool2 = max_pool_2x2(h_conv2)

    with tf.name_scope('fc1') as scope:
        W_fc1 = weight_variable([7 * 7 * 64, 1024])
        b_fc1 = bias_variable([1024])
        h_pool2_flat = tf.reshape(h_pool2, [-1, 7 * 7 * 64])
        h_fc1 = tf.nn.relu(tf.matmul(h_pool2_flat, W_fc1) + b_fc1)
        h_fc1_drop = tf.nn.dropout(h_fc1, keep_prob)

    with tf.name_scope('fc2') as scope:
        W_fc2 = weight_variable([1024, NUM_CLASSES])
        b_fc2 = bias_variable([NUM_CLASSES])

    with tf.name_scope('softmax') as scope:
        y_conv=tf.nn.softmax(tf.matmul(h_fc1_drop, W_fc2) + b_fc2)

    return y_conv

if __name__ == '__main__':
    test_image = []
    test_image_name = []
    files = os.listdir(DIR_PATH)
    for file in files:
        if file == '.DS_Store':
            continue

        img = cv2.imread(DIR_PATH + file)
        img = cv2.resize(img, (IMAGE_SIZE, IMAGE_SIZE))
        test_image.append(img.flatten().astype(np.float32) / 255.0)
        test_image_name.append(file)

    test_image = np.asarray(test_image)

    images_placeholder = tf.placeholder('float', shape=(None, IMAGE_PIXELS))
    labels_placeholder = tf.placeholder('float', shape=(None, NUM_CLASSES))
    keep_prob = tf.placeholder('float')

    logits = inference(images_placeholder, keep_prob)
    sess = tf.InteractiveSession()

    saver = tf.train.Saver()
    sess.run(tf.initialize_all_variables())
    saver.restore(sess,FLAGS.readmodels)

    for i in range(len(test_image)):
        pr = logits.eval(feed_dict={
            images_placeholder: [test_image[i]],
            keep_prob: 1.0
        })[0]
        pred = np.argmax(pr)

        if pred == 1:
            # 好きな芸能人と判定された場合
            print('%s,%f' % (test_image_name[i], pr[pred] * 100.0))

結果は標準出力に出ますので、適宜ファイルにリダイレクションください。
結果をスコア降順にソートして出力して完成!

$ cat result.csv | sort -r -t, -k 2 | head -5
    1xxxx1_1.jpg,0.5406388165003011
    1xxxx1_2.jpg,0.5350551152698707
    1xxxx6_1.jpg,0.5310078821076752
    1xxxx2_1.jpg,0.5183026050695199
    1xxxx0_1.jpg,0.5130400958800978

その後

なんかよく分からないですが、弊社の部会で取り組み事例として紹介いただきました。

参考リンク

15
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?