はじめに
最近Youtubeで月の満ち欠けの絵文字を使って、「ヒ○キン大好き」等の文章を書くのが流行っているようです。
自動で書いてくれるスクリプトを探したのですが見つからなかったので、Pythonを使って書いてみました。
そしてせっかくなので、Herokuにのせて公開(Moon mosaic)するところまでやってみました。
実行環境
- OS: MacOS Mojave
- Python 3.6.2
使用したPythonライブラリ
- Pillow 4.3.0
- numpy 1.13.3
- Flask 1.0.2
大まかな手順
- 受け取った文字列を1文字づつ画像に変換
- 画像を4×4ピクセルずつ読み込んで月の絵文字にマッピング
- 縦または横の空白(新月)を除去
- 文字を連結
- 完成
テキストを画像に変換
PILモジュールを使って画像を生成します。
月の絵文字の分解能は4なので(←伝わりづらいけどわかって)、画像の縦と横のピクセル数を4の倍数にしています。
フォントは好きなものを使えば良いですが、ボールドのものがオススメです。細いフォントは絵文字に変換した時、表示されなくなってしまいます。
from PIL import Image, ImageDraw, ImageFont
SLICE_SIZE = 4
def text_to_image(size, text, pixels):
img = Image.new("L", size, 255)
draw = ImageDraw.Draw(img)
draw.font = ImageFont.truetype(font='fonts/KozGoPr6N-Bold.otf', size=int(SLICE_SIZE * pixels), encoding='utf-8')
draw.text((0, 0), text)
return img
pixels = 20
size = (SLICE_SIZE * pixels, SLICE_SIZE * pixels)
text = 'あ'
img = text_to_image(size, text, pixels)
img.show()
出力画像
numpy.arrayに変換
PILのImageクラスは、numpyのarrayに変換することができます。
これを利用して2次元配列に落とし込みます。
import numpy as np
img = np.array(img)
月の絵文字にマッピング
まず、月の満ち欠けの絵文字は8パターンあるので、以下の法則に従ってマッピングします。
0000 → 🌑
0001 → 🌒
0011 → 🌓
0111 → 🌔
1000 → 🌘
1100 → 🌗
1110 → 🌖
1111 → 🌕
なんとなく分かるかもしれませんが、1が月の光っている部分を表しています。2進数で空いている箇所も一番近い絵文字で埋めていきます。
0 → 0000 → 🌑
1 → 0001 → 🌒
2 → 0010 → 🌓
3 → 0011 → 🌓
4 → 0100 → 🌗
5 → 0101 → 🌔
6 → 0110 → 🌕
7 → 0111 → 🌔
8 → 1000 → 🌘
9 → 1001 → 🌑
10 → 1010 → 🌖
11 → 1011 → 🌕
12 → 1100 → 🌗
13 → 1101 → 🌕
14 → 1110 → 🌖
15 → 1111 → 🌕
するとこのようになります。このままだと冗長なのでpythonで書く際は以下のように書き直します。MOONS[MOON_INDEX[x]]
とすることで、指定したindexの絵文字を取得できます。
MOONS = ['🌕', '🌖', '🌗', '🌘', '🌑', '🌒', '🌓', '🌔']
MOON_INDEX = [4, 5, 6, 6, 2, 7, 0, 7, 3, 4, 1, 0, 2, 0, 1, 0]
# 取得例
MOONS[MOON_INDEX[0]] # 🌑
MOONS[MOON_INDEX[5]] # 🌔
ここからが本題ですが、np.array化したイメージを4×4ピクセルごとに、月の絵文字に変換していきます。
# 配列になっている2進数を10進数に変換
# (例) in: [0, 0, 1, 0], out: 2
def array_to_number(arr):
return int('0b{0[0]}{0[1]}{0[2]}{0[3]}'.format([1 if i else 0 for i in arr]), 0)
moon_text = ''
for y in range(0, size[1], 4):
for x in range(0, size[0], 4):
block = img[y:y + 4, x:x + 4]
col_ave = np.average(block, axis=0) # 列要素の平均をとった1次元配列に圧縮
num = array_to_number(col_ave < np.array([127.5] * 4))
moon_text += MOONS[MOON_INDEX[num]]
moon_text += '\n'
print(moon_text)
簡単に月の満ち欠けの絵文字にマッピングすることができました。
基本的な部分は完成です。
空白の削除と文字の結合
このままでもいいのですが、特に英数字などは絵文字変換すると左右に空白ができてしまって、繋げた時に間隔が空いてしまいます。
これを解決するために、空白を削ります。今回は文字を横につなぐ場合(vertical = True
)と、縦につなぐ場合(vertical = False
)を想定しています。
各文字の空白を削除したら文字同士を接続します。その際、縦書きか横書きかによって結合方向を変更します。
ちなみに、縦書きであれば上下の空白を、横書きであれば左右の空白を削除しています。
# 空白の数を取得
def get_margin_size(col_max):
top = 0
bottom = 0
for i in col_max:
if i != 0:
break
top += 1
for i in col_max[::-1]:
if i != 0:
break
bottom += 1
return top - 1 if top > 0 else 0, bottom - 1 if bottom > 0 else 0
# 空白の削除
def drop_margin(np_list, vertical):
col_max = np.max(np_list, axis=1 if vertical else 0)
top, bottom = get_margin_size(col_max) # 空白の数を取得
if top > 0:
np_list = np_list[top:, :] if vertical else np_list[:, top:]
if bottom > 0:
np_list = np_list[:-bottom, :] if vertical else np_list[:, :-bottom]
return np_list
# 文字を結合
def connect_words(np_list, vertical):
base = np_list[0]
for word in np_list[1:]:
base = np.vstack((base, word)) if vertical else np.hstack((base, word))
return base
def draw_moon_mosaic(img, size, pixels, vertical):
np_list = np.empty((0, pixels), int)
for y in range(0, size[1], 4):
s = np.array([])
for x in range(0, size[0], 4):
block = img[y:y + 4, x:x + 4]
col_ave = np.average(block, axis=0)
num = array_to_number(col_ave < np.array([127.5] * 4))
s = np.append(s, num)
np_list = np.append(np_list, np.reshape(s, (1, pixels)), axis=0)
np_list = drop_margin(np_list, vertical) # 空白の削除
return np_list
text = 'ab'
vertical = False # 横書き
np_list = []
for word in list(text):
size = (4 * pixels, 4 * pixels)
img = text_to_image(size, word, pixels)
np_list.append(draw_moon_mosaic(np.array(img), size, pixels, vertical))
index_text = connect_words(np_list, vertical) # 文字を結合
moon_text = '\n'.join([''.join(list(map(lambda x: MOONS[MOON_INDEX[int(x)]], i))) for i in index_text])
print(moon_text)
綺麗に結合することができました。
Flaskを使ってアプリケーションにまとめる
月の満ち欠けの絵文字で文字列を生成することはできました。せっかくなので、最後にwebアプリケーションにまとめてみます。
ディレクトリ構成は以下のようにします。フォントは使用したいものをfonts/
ディレクトリに設置して、cssファイルは https://bulma.io/ からダウンロードしてきてstatic/css/
ディレクトリに設置します。
.
├── app.py
├── fonts
│ └── KozGoPr6N-Bold.otf
├── static
│ └── css
│ └── bulma.min.css
└── templates
└── index.html
Webフレームワークは、軽量で最近勢いのあるFlaskを採用しました。htmlは、jinja2というテンプレートエンジンを使って生成します。
<!doctype html>
<html>
<head>
<title>Moon Mosaic</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/bulma.min.css') }}">
<script>
$(function() {
var $textarea = $('#textarea');
var lineHeight = parseInt($textarea.css('lineHeight'));
$textarea.on('input', function(e) {
var lines = ($(this).val() + '\n').match(/\n/g).length;
$(this).height(lineHeight * lines);
});
});
</script>
</head>
<body>
<div class="container">
<section class="hero">
<div class="hero-body">
<div class="container">
<h1 class="title">月文字</h1>
<h2 class="subtitle">Moon mosaic</h2>
</div>
</div>
</section>
</div>
<div class="container is-fluid">
<div class="moon is-mobile">
<form method="post" action="{{ url_for('index')}}">
<div class="columns is-mobile">
<div class="column is-one-quarter">
<label class="checkbox">
<input type="checkbox" name="vertical" value="{{ params.vertical }}">
縦書き
</label>
</div>
</div>
<div class="columns is-mobile">
<div class="column is-2">
<div class="control">
<input class="input" type="number" name="pixels" placeholder="ピクセル数" value="{{ params.pixels }}">
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<div class="control">
<input class="input" type="text" name="text" placeholder="ここに入力" value="{{ params.text }}">
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column">
<button class="button is-dark is-large is-two-thirds" type="submit">実行</button>
</div>
</div>
</form>
{% if out %}
<div class="columns is-mobile ">
<div class="column">
<textarea class="textarea" id="textarea" rows="{{ rows }}" wrap="off" readonly>{{ out }}</textarea>
</div>
</div>
{% endif %}
</div>
</div>
</body>
</html>
from PIL import Image, ImageDraw, ImageFont
import numpy as np
from flask import Flask, render_template, request
app = Flask(__name__)
SLICE_SIZE = 4
MOONS = ['🌕', '🌖', '🌗', '🌘', '🌑', '🌒', '🌓', '🌔']
MOON_INDEX = [4, 5, 6, 6, 2, 7, 0, 7, 3, 4, 1, 0, 2, 0, 1, 0]
def array_to_number(arr):
return int('0b{0[0]}{0[1]}{0[2]}{0[3]}'.format([1 if i else 0 for i in arr]), 0)
def text_to_image(size, text, pixels):
img = Image.new("L", size, 255)
draw = ImageDraw.Draw(img)
draw.font = ImageFont.truetype(font='fonts/KozGoPr6N-Bold.otf', size=int(SLICE_SIZE * pixels), encoding='utf-8')
draw.text((0, 0), text)
return img
def get_margin_size(col_max):
top = 0
bottom = 0
for i in col_max:
if i != 0:
break
top += 1
for i in col_max[::-1]:
if i != 0:
break
bottom += 1
return top - 1 if top > 0 else 0, bottom - 1 if bottom > 0 else 0
def drop_margin(np_list, vertical):
col_max = np.max(np_list, axis=1 if vertical else 0)
top, bottom = get_margin_size(col_max)
if top > 0:
np_list = np_list[top:, :] if vertical else np_list[:, top:]
if bottom > 0:
np_list = np_list[:-bottom, :] if vertical else np_list[:, :-bottom]
return np_list
def draw_moon_mosaic(img, size, pixels, vertical):
np_list = np.empty((0, pixels), int)
for y in range(0, size[1], SLICE_SIZE):
s = np.array([])
for x in range(0, size[0], SLICE_SIZE):
block = img[y:y + SLICE_SIZE, x:x + SLICE_SIZE]
col_ave = np.average(block, axis=0)
num = array_to_number(col_ave < np.array([127.5] * 4))
s = np.append(s, num)
np_list = np.append(np_list, np.reshape(s, (1, pixels)), axis=0)
np_list = drop_margin(np_list, vertical)
return np_list
def connect_words(np_list, vertical):
base = np_list[0]
for word in np_list[1:]:
base = np.vstack((base, word)) if vertical else np.hstack((base, word))
return base
def string_to_moon(vertical, pixels, text):
np_list = []
for word in list(text):
size = (SLICE_SIZE * pixels, SLICE_SIZE * pixels)
img = text_to_image(size, word, pixels)
np_list.append(draw_moon_mosaic(np.array(img), size, pixels, vertical))
index_text = connect_words(np_list, vertical)
moon_text = '\n'.join([''.join(list(map(lambda x: MOONS[MOON_INDEX[int(x)]], i))) for i in index_text])
# print(moon_text)
return moon_text, np.shape(index_text)[0]
@app.route('/', methods=["GET", "POST"])
def index():
if request.method == 'GET':
params = {
'vertical': False,
'pixels': 15,
'text': ''
}
return render_template('index.html', params=params)
elif request.method == 'POST':
params = {
'vertical': 'vertical' in request.form,
'pixels': int(request.form['pixels']),
'text': request.form['text'] if 'text' in request.form else ''
}
out, rows = string_to_moon(params['vertical'], params['pixels'], params['text'])
return render_template('index.html', params=params, out=out, rows=rows)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=80)
あとはpythonを実行して、http://0.0.0.0:80/ にアクセスするだけです。
$ python app.py
* Serving Flask app "app" (lazy loading)
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: off
* Running on http://0.0.0.0:80/ (Press CTRL+C to quit)
Herokuに乗せるところは今回は省略しますが、とてつもなく簡単です。おヒマでしたらやってみてはいかがでしょうか。