機械学習を用いて夜空に浮かぶ星座を判別したかった話

初めまして。aidemy研修生の笹川です。

これから空気が澄んで星の見やすい季節になりますね。
冬であれば、あれはオリオン座であれはふたご座。。。
そのくらいは言える方が多いかも知れませんが、
もっと難しい星座も見つけられたら格好いいし、
星空を見るのが楽しくなると思いませんか?

しかし、何の当てもなく星座を見分けることは容易ではありません。
そこで今回は星座の画像を学習し、機械の力で夜空の星座を判別することができないかやってみようと思います。

0. 実行環境

macOS Mojave 10.14
Python 3.6.3
jupyter notebook

1. データ

1.1 星座の種類について

判別を試みる星座は以下の5種類の冬の星座です。
実際の星空ではこのように見えます。

f:id:s-sasagawa0185:20181115054007p:plain
5種類の冬の星座

Taurus:おうし座 Orion:オリオン座 Auriga:ぎょしゃ座
Canis_Minor:おおいぬ座 Gemini:ふたご座

それぞれ、アルデバラン、ベテルギウス、カペラ、シリウス、ポルックスと特徴的な星を持っており、人間の目であれば
それらを目当てにすればどこら辺にあるかを判別することはさほど難しくありません。
都内で星を見ること自体はなかなか難しいですが。。。

1.2 画像収集

奇麗に星座が写った星空の画像はあまりありませんでした。
記事の趣旨とずれる懸念はありましたが、星座の形さえ学習できればよいと考え、
実際の星空でなくてもそれに近い画像は積極的に学習用に採用しました。

とはいえ、専門的に膨大な画像を取り上げているウェブサイトは見つけられなかったため、画像の収集には
スクレイピングはあまり使えず、ほぼ手作業となりました。

#ライブラリのインポート
import numpy as np
import cv2
import urllib.request
import os
from PIL import Image
import glob
#フォルダの作成
winter_constellations = ["Taurus", "Orion", "Auriga", "Canis_Minor", "Gemini"]
for const in winter_constellations:
    if not os.path.exists("./train/" + const):
        os.mkdir("./train/" + const)
    
    if not os.path.exists("./test/" + const):
        os.mkdir("./test/" + const)  

画像収集例として、サイト「星座入門」から
の画像のダウンロードを行ったコードを記載します。

for const in winter_constellations:
    url = "http://mirahouse.jp/begin/constellation/" + const + "03.gif"
    file = const + "03.gif"
    path = "./train/" + const + "/"
    
    if not os.path.exists(path + file):
        #ダウンロード実行
        urllib.request.urlretrieve(url, path + file)
    
    if os.path.exists(path + file):
        #jpgに変換
        img = Image.open(path + file)
        #"0001.jpg"という名前で保存
        img.save(path + "0001.jpg")
        #ダウンロードしたgifファイルを削除
        os.remove(path + file)

1.3 画像増幅

各30枚程度ずつ集め、以下のように回転、閾値処理を行いました。

#回転
angles = [60, 120, 180, 240, 300]

for const in winter_constellations:
    path = "./train/" + const + "/"
    files = glob.glob(path + "/*.jpg")
    for i, file in enumerate(files):
        img = Image.open(file)
        for angle in angles:
            tmp = img.rotate(angle)
            tmp.save(path + str(i) + "_" + str(angle) + ".jpg")
#閾値処理
levels = [50, 100, 150, 200, 250]

for const in winter_constellations:
    path = "./train/" + const + "/"
    files = glob.glob(path + "/*.jpg")
    for i,file in enumerate(files):
        img = cv2.imread(file)
        for level in levels:
            thr = lambda x: cv2.threshold(x, level, 255, cv2.THRESH_BINARY)[1]
            img = thr(img)
            cv2.imwrite(path + str(i) + "_" + str(level) + ".jpg" ,img)

以上のようにして各400枚程度作成した画像データを、8:2の割合で学習用と評価用に分けました。
フォルダごとに以下のコードを実行し、画像ファイルは連番にしました。

ls *.jpg | awk '{ printf "mv %s %04d.jpg\n", $0, NR }' | sh

2. 分類

2.1 CNNによる分類

まずはCNNという手法を使って分類を試みます。
kerasが公開しているexampleから、minst_cnnのモデルをお借りしました。

#ライブラリ等のインポート
import keras
from keras.utils import np_utils
from keras.layers.convolutional import Conv2D, MaxPooling2D
from keras.models import Sequential
from keras.layers.core import Dense, Dropout, Activation, Flatten
import numpy as np
import glob
import cv2
from PIL import Image
#基本情報の設定
winter_constellations = ["Taurus", "Orion", "Auriga", "Canis_Minor", "Gemini"]
image_size = 224
#学習データの設定
train_X = []
train_Y = []
for index, const in enumerate(winter_constellations):
path = "./" + const
files = glob.glob(path + "/*.jpg")
for file in files:
image = Image.open(file)
image = image.convert("RGB")
image = image.resize((image_size, image_size))
data = np.asarray(image)
train_X.append(data)
train_Y.append(index)
train_X = np.asarray(train_X)
train_Y = np.asarray(train_Y)
#正規化
train_X = train_X.astype("float32")
train_X = train_X/255.0
#one-hot表現に変換
train_Y = np_utils.to_categorical(train_Y, 5)
#モデルの設定
batch_size = 128
num_classes = 5
model = Sequential()
model.add(Conv2D(32, kernel_size=(3, 3),
activation='relu',
input_shape=train_X.shape[1:]))
model.add(Conv2D(64, (3, 3), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(128, activation='relu'))
model.add(Dropout(0.5))
model.add(Dense(num_classes, activation='softmax'))
model.compile(loss=keras.losses.categorical_crossentropy,
optimizer=keras.optimizers.Adadelta(),
metrics=['accuracy'])
#学習の実行
history = model.fit(train_X, train_Y,
batch_size=batch_size,
epochs=50,
verbose=1,
validation_data=(test_X, test_Y))
#学習の推移
Train on 1627 samples, validate on 407 samples
Epoch 1/50
1627/1627 [==============================] - 13s 7ms/step - loss: 1.9747 - acc: 0.3183 - val_loss: 2.3256 - val_acc: 0.1111
Epoch 2/50
1627/1627 [==============================] - 12s 6ms/step - loss: 1.1243 - acc: 0.7020 - val_loss: 2.6229 - val_acc: 0.1778
Epoch 3/50
1627/1627 [==============================] - 12s 6ms/step - loss: 0.5156 - acc: 0.8563 - val_loss: 2.6733 - val_acc: 0.2667
…
Epoch 47/50
1627/1627 [==============================] - 13s 7ms/step - loss: 0.0502 - acc: 0.9862 - val_loss: 10.5200 - val_acc: 0.1556
Epoch 48/50
1627/1627 [==============================] - 12s 7ms/step - loss: 0.0469 - acc: 0.9878 - val_loss: 10.5937 - val_acc: 0.2222
Epoch 49/50
1627/1627 [==============================] - 12s 7ms/step - loss: 0.0683 - acc: 0.9851 - val_loss: 10.6505 - val_acc: 0.1556
Epoch 50/50
1627/1627 [==============================] - 12s 7ms/step - loss: 0.0503 - acc: 0.9867 - val_loss: 10.2061 - val_acc: 0.1778

正答率は全く上がりませんでした。
ひとまず違う方法に移ることにしました。

2.2 転移学習の利用

以下のウェブサイトを参考に転移学習を行うことにしました。
少ない画像から画像分類を学習させる方法(kerasで転移学習:fine tuning) | SPJ
Keras / Tensorflowで転移学習を行う – Qiita

VGG16という学習済みのモデルを使います。
「学習用のデータが少ない場合に有効」という文言に強く惹かれたわけですが、星座の判別に
適当かどうかは定かではありませんでした。

#ライブラリ等のインポート
import keras
from keras.applications.vgg16 import VGG16
from keras.preprocessing.image import ImageDataGenerator
from keras.layers import Dense, GlobalAveragePooling2D, Input
import keras.callbacks
from keras.layers.convolutional import Conv2D, MaxPooling2D
from keras.models import Model, Sequential
from keras.layers.core import Dense, Dropout, Activation, Flatten
#基本情報の設定
N_CATEGORIES  = 5
IMAGE_SIZE = 224
BATCH_SIZE = 32
NUM_TRAINING = 2000
NUM_VALIDATION = 400
#モデルの設定
input_tensor = Input(shape=(IMAGE_SIZE, IMAGE_SIZE, 3))
base_model = VGG16(weights='imagenet', include_top=False,input_tensor=input_tensor)
x = base_model.output
x = GlobalAveragePooling2D()(x)
x = Dense(1024, activation='relu')(x)
predictions = Dense(N_CATEGORIES, activation='softmax')(x)
model = Model(inputs=base_model.input, outputs=predictions)
for layer in base_model.layers:
layer.trainable = False
from keras.optimizers import SGD
model.compile(optimizer=SGD(lr=0.0001, momentum=0.9), loss='categorical_crossentropy',metrics=['accuracy'])
model.summary()
#学習用、評価用データの作成
#ImageDataGeneratorは回転やズームを自動的に行い枚数を水増ししてくれます。
image_data_generator = ImageDataGenerator(rescale=1.0/255)
train_data = image_data_generator.flow_from_directory(
'./train',
target_size=(224, 224),
batch_size=32,
class_mode='categorical',
shuffle=True
)
image_data_generator = ImageDataGenerator(rescale=1.0/255)
validation_data = image_data_generator.flow_from_directory(
'./test',
target_size=(224, 224),
batch_size=32,
class_mode='categorical',
shuffle=True
)
#学習の実行
histry = model.fit_generator(train_data,
steps_per_epoch = NUM_TRAINING//BATCH_SIZE,
epochs=50,
verbose=1,
validation_data = validation_data,
validation_steps = NUM_VALIDATION//BATCH_SIZE,
)
Epoch 1/50
56/56 [==============================] - 1298s 23s/step - loss: 2.2271 - acc: 0.1439 - val_loss: 2.2002 - val_acc: 0.0185
Epoch 2/50
56/56 [==============================] - 1145s 20s/step - loss: 2.1892 - acc: 0.1686 - val_loss: 2.1966 - val_acc: 0.1111
Epoch 3/50
56/56 [==============================] - 1175s 21s/step - loss: 2.1831 - acc: 0.1578 - val_loss: 2.1955 - val_acc: 0.0370
Epoch 4/50
56/56 [==============================] - 1145s 20s/step - loss: 2.1809 - acc: 0.1837 - val_loss: 2.1951 - val_acc: 0.1296
Epoch 5/50
56/56 [==============================] - 1130s 20s/step - loss: 2.1751 - acc: 0.1755 - val_loss: 2.1957 - val_acc: 0.1852
Epoch 6/50
56/56 [==============================] - 1156s 21s/step - loss: 2.1742 - acc: 0.1836 - val_loss: 2.1959 - val_acc: 0.1111
Epoch 7/50
56/56 [==============================] - 1159s 21s/step - loss: 2.1684 - acc: 0.2077 - val_loss: 2.1943 - val_acc: 0.0556

ここまで行って(2時間弱)、時間のかかる割に正答率が高まらないと思い、中断しました。

3. 物体検出

この問題は、画像分類の問題ではなく、画像のセグメンテーションや領域抽出
ないしは物体検出の問題
ではないかと思い始めました。
そして、以下の二つの方法を考えました。

①U-Net(U字型のニューラルネットワーク)による領域抽出
参考ページ:
U-net構造で、画像セグメンテーションしてみた。(2) – Qiita
Deep learningで画像認識⑨〜Kerasで畳み込みニューラルネットワーク vol.5〜 – IMACEL Academy -人工知能・画像解析の技術応用に向けて-|LPixel(エルピクセル)

②AWSのSageMakerで提供されているアルゴリズムを用いた物体検出

より手軽そうな後者を選択しました。

3.1 SageMakerによる物体検出

ベースとなるニューラルネットはResNetです。

SageMakerで「うまい棒検出モデル」を作ってみた | DevelopersIO
[新機能] SageMakerが物体検出アルゴリズムをサポートしました | DevelopersIO
Object Detection Algorithm – Amazon SageMaker

上記ウェブサイト及び、Amazon SageMakerのexampleとしてある
Object Detection using the Image and JSON formatを参考にしています。

使用する画像データは前述した処理で増幅しています。

3.2 アノテーション

この方法で学習に更に必要となるのが「アノテーション」という段階です。
各画像のどこに何があるかという情報を手作業で作成し、jsonファイルとして出力します。
その際に用いるのが、VoTT(Visual Object Tagging Tool)です。

f:id:s-sasagawa0185:20181115054025p:plain
VoTTによるアノテーション

各星座を囲み、どの星座であるかを記録します。
上図はOrionとTaurusを囲んでいます。

この作業を繰り返すと、一つのフォルダに入った複数の画像に対して、一つのjsonファイルが出力されます。
それを以下のコードで個々の画像に対応したものとします。
このコードでは学習を行う際に画像が連番となっていることが必須なので、
アノテーションの前に必ず連番にしておきましょう。(1敗)

import json
file_name = './annotation.json'
class_list = {'Taurus':0, 'Orion':1, 'Gemini':2}
#ここではぎょしゃ座とおおいぬ座を削り、3種類の星座で行っています。
with open(file_name) as f:
js = json.load(f)
for k, v in js['frames'].items():
k = int(k)
line = {}
line['file'] = '{0:04d}'.format(k+1) + '.jpg'
line['image_size'] = [{
'width':int(v[0]['width']),
'height':int(v[0]['height']),
'depth':3
}]
line['annotations'] = []
for annotation in v:
line['annotations'].append(
{
'class_id':class_list[annotation['tags'][0]],
'top':int(annotation['y1']),
'left':int(annotation['x1']),
'width':int(annotation['x2'])-int(annotation['x1']),
'height':int(annotation['y2']-int(annotation['y1']))
}
)
line['categories'] = []
for name, class_id in class_list.items():
line['categories'].append(
{
'class_id':class_id,
'name':name
}
)
f = open('./json/'+'{0:04d}'.format(k+1) + '.json', 'w')
json.dump(line, f)

これを実行すると、画像の枚数分だけjsonファイルが作成されます。

3.3 データのアップロード

次に、SageMakerから作成した画像データとjsonデータにアクセスできるようにするため、
Amazon S3にアップロードします。
S3の作成の際にはバケット名に「sagemaker」を含めるようにしてください。
そして、SageMakerのインスタンスを作成します。

【初心者向け】Amazon SageMakerではじめる機械学習 #SageMaker | DevelopersIO
に詳しく記載されています。

3.4 SageMakerで学習モデル構築/エンドポイント作成

インスタンスの作成後、実行環境となるjupyter notebookを開き、
Newからnotebookのconda_mxnet_p36を作成します。

import sagemaker
from sagemaker import get_execution_role
role = get_execution_role()
print(role)
sess = sagemaker.Session()
bucket = 'bucketの名前' #作成したバケット名を記入
#学習用イメージURLの取得
from sagemaker.amazon.amazon_estimator import get_image_uri
training_image = get_image_uri(sess.boto_region_name, 'object-detection', repo_version="latest")
print (training_image)
#入力データの設定
train_channel = 'train'
validation_channel = 'validation'
train_annotation_channel = 'train_annotation'
validation_annotation_channel = 'validation_annotation'
s3_train_data = 's3://{}/{}'.format(bucket, train_channel)
s3_validation_data = 's3://{}/{}'.format(bucket, validation_channel)
s3_train_annotation = 's3://{}/{}'.format(bucket, train_annotation_channel)
s3_validation_annotation = 's3://{}/{}'.format(bucket, validation_annotation_channel)
s3_output_location = 's3://{}/{}/output'.format(bucket, prefix)
#アルゴリズム設定
od_model = sagemaker.estimator.Estimator(training_image,
role,
train_instance_count=1,
train_instance_type='ml.t2.medium',
train_volume_size = 50,
train_max_run = 360000,
input_mode = 'File',
output_path=s3_output_location,
sagemaker_session=sess)
#ハイパーパラメーターの設定
od_model.set_hyperparameters(base_network='resnet-50',
use_pretrained_model=1,
num_classes=3,
mini_batch_size=10,
epochs=50,
learning_rate=0.001,
lr_scheduler_step='10',
lr_scheduler_factor=0.1,
optimizer='sgd',
momentum=0.9,
weight_decay=0.0005,
overlap_threshold=0.5,
nms_threshold=0.45,
image_shape=512,
label_width=350,
num_training_samples=360)
#データチャネルとアルゴリズムの間でハンドシェイク
train_data = sagemaker.session.s3_input(s3_train_data, distribution='FullyReplicated',
content_type='image/jpeg', s3_data_type='S3Prefix')
validation_data = sagemaker.session.s3_input(s3_validation_data, distribution='FullyReplicated',
content_type='image/jpeg', s3_data_type='S3Prefix')
train_annotation = sagemaker.session.s3_input(s3_train_annotation, distribution='FullyReplicated',
content_type='image/jpeg', s3_data_type='S3Prefix')
validation_annotation = sagemaker.session.s3_input(s3_validation_annotation, distribution='FullyReplicated',
content_type='image/jpeg', s3_data_type='S3Prefix')
data_channels = {'train': train_data, 'validation': validation_data,
'train_annotation': train_annotation, 'validation_annotation':validation_annotation}
#学習の実行とモデルの作成
od_model.fit(inputs=data_channels, logs=True)

20分程かかりました。

#エンドポイント作成
object_detector = od_model.deploy(initial_instance_count = 1,
instance_type = 'ml.t2.medium')

3.5 推論

以上の処理が完了した後、いくつかの画像をjupyter notebookにアップロードし、推論を行いました。

file_name = 'test1.jpg'
with open(file_name, 'rb') as image:
f = image.read()
b = bytearray(f)
ne = open('n.txt','wb')
ne.write(b)
import json
object_detector.content_type = 'image/jpeg'
results = object_detector.predict(b)
detections = json.loads(results)
print (detections)

jsonの形式で推論の結果が出力されます。数字の羅列で何が何だかわからないので以下のコードで可視化します。

def visualize_detection(img_file, dets, classes=[], thresh=0.6):
"""
        visualize detections in one image
        Parameters:
        ----------
        img : numpy.array
            image, in bgr format
        dets : numpy.array
            ssd detections, numpy.array([[id, score, x1, y1, x2, y2]...])
            each row is one object
        classes : tuple or list of str
            class names
        thresh : float
            score threshold
        """
import random
import matplotlib.pyplot as plt
import matplotlib.image as mpimg
img=mpimg.imread(img_file)
plt.imshow(img)
height = img.shape[0]
width = img.shape[1]
colors = dict()
for det in dets['prediction']:
(klass, score, x0, y0, x1, y1) = det
if score < thresh:
continue
cls_id = int(klass)
if cls_id not in colors:
colors[cls_id] = (random.random(), random.random(), random.random())
xmin = int(x0 * width)
ymin = int(y0 * height)
xmax = int(x1 * width)
ymax = int(y1 * height)
rect = plt.Rectangle((xmin, ymin), xmax - xmin,
ymax - ymin, fill=False,
edgecolor=colors[cls_id],
linewidth=3.5)
plt.gca().add_patch(rect)
class_name = str(cls_id)
if classes and len(classes) > cls_id:
class_name = classes[cls_id]
plt.gca().text(xmin, ymin - 2,
'{:s} {:.3f}'.format(class_name, score),
bbox=dict(facecolor=colors[cls_id], alpha=0.5),
fontsize=12, color='white')
plt.show()
object_categories = ['Taurus', 'Orion', 'Gemini']
# Setting a threshold 0.20 will only plot detection results that have a confidence score greater than 0.20.
#thresholdとは閾値のことです。
threshold = 0.2
# Visualize the detections.
visualize_detection(file_name, detections, object_categories, threshold)

結果は、以下のようになり、何も検出されませんでした。

f:id:s-sasagawa0185:20181115060710p:plain
物体検出の結果

オリオン座くらいは検出されてもいいのではないかと思いましたが、
閾値の高低は関係なく検出されない、という結果となりました。

4. 考察

分類や検出が全くうまくいかなかった原因を考えました。

星空に映る星座の画像が少なかった
まず、画像データがあまりなかったことが挙げられます。この時点でこのテーマを断念すべきでしたが、
画像増幅によってなんとかできると楽観視していました。

パラメーターの調整が不適切
これに関しては完全に知識不足です。ドキュメントを読み直してより良いモデルの構築ができるよう
励んでいきます。

星座の画像は学習するのには適していない
黒い背景に無数の白い点がある中で、特定の疎らな点の並びを「あるもの」
と判別することは、人間の目でも難しいので、無理があったのかもしれません。
しかし、白黒画像からの分類等も多く行われているので、
力不足であったことは確かです。
閾値処理などをもっとうまくできれば判別できる可能性もあります。

また、星座の形(星の並び方)をよく学習できれば希望はあるかも
知れないと思いました。 どういう風にそれが実装できるかは更に
考えていきたいです。

5. まとめ

・CNN、VGG16の転移学習、AWSのSageMakerでの物体検出という、
機械学習の三つの方法で、夜空の星座を判別できないかを試みました。

・全ての方法で惨敗し、判別ができないという結果となりました。

・データの更なる収集やパラメーターの調整など、学習の方法を改善できる
箇所が多くあると考えられました。

筆者は星の案内人という資格を持っていて、より星の良さを広められたらという思いがあり、
このテーマに挑戦しました。
Star Chartなどのスマートフォンアプリでは緯度、経度を入力した上で、方角や端末の傾きに応じて星座を表示しています。
やはり実際の星空から機械によって判別することは難しいのかも知れません。
よく考えると、古き良き星座早見盤を使って頑張って星座を探すのも楽しみの一つ。
皆さんも、空気の澄んだ夜には空を見上げてみるといいことがあるかも知れません。

身も蓋もない記事となってしまいましたが、読んでいただきありがとうございました。