Slackのカスタム絵文字、楽しいですよね。
さて、ダークモードで使ってる方ならあるあるかもしれませんが、ダークモードにしてしまうとこんな問題が発生します。
_人人人人人人人人人人_
> 絵文字が見えない <
 ̄Y^Y^Y^Y^Y^Y^Y^Y^Y ̄
絵文字の背景を透明ではなく白にすることで解決してらっしゃった記事もありましたが、今回は上の画像で黄色く囲んだ「オッ」のように、縁取りをする方法で解決したいと思います。
今回作成したソースコードは、以下のリポジトリに置いてあります。是非使ってみてください!
1. 絵文字を取り込む
まずは、Slackから絵文字画像を取ってきます。これは比較的簡単で、emoji.list
APIを使うと取得することができます。
emoji:read
のスコープを設定したアプリケーションを作成してトークンを取得したら、以下のアドレスを叩くとJSONが得られます。
https://slack.com/api/emoji.list?token=xoxb-...
{
"ok": true,
"emoji": {
"bowtie": "https:\/\/emoji.slack-edge.com\/xxxxxx\/bowtie\/yyyyyy.png",
"squirrel": "https:\/\/emoji.slack-edge.com\/xxxxxx\/squirrel\/zzzzzz.png",
}
}
これをもとにPythonでシュシュっと取ってくるプログラムを書きます。
import os
from time import sleep
import requests
def download_image(url):
response = requests.get(url)
if response.status_code != 200:
print(f"HTTP Error: {response}")
return None
content_type = response.headers["content-type"]
if 'image' not in content_type:
print(f"Error: {content_type} is not image")
return None
return response.content
def make_filename(base_dir, alias, url):
ext = os.path.splitext(url)[1]
filename = alias + ext
fullpath = os.path.join(base_dir, filename)
return fullpath
def save_image(path, image):
with open(path, "wb") as fout:
fout.write(image)
def main():
TOKEN = os.getenv('SLACK_TOKEN')
BASE_DIR = './original/'
os.makedirs(BASE_DIR, exist_ok=True)
res = requests.get('https://slack.com/api/emoji.list',
headers={'Authorization': f'Bearer {TOKEN}'})
emojis = res.json()['emoji']
for alias, emoji_url in emojis.items():
if (emoji_url.startswith('alias:')):
continue
img = download_image(emoji_url)
if img is None:
continue
img_path = make_filename(BASE_DIR, alias, emoji_url)
save_image(img_path, img)
sleep(1)
if __name__ == "__main__":
main()
注意すべき点は、エイリアスを設定しているとそれも含まれてしまうので除外する処理が必要です。
2. フチを抽出する
フチの抽出にはラプラシアンフィルタを使います。
今回は画像処理全般をPillowで行いました。当初、OpenCVも試しましたが、一部絵文字のアルファチャンネルが正常に読み込めなかったので諦めました。
f = Image.open('hoge.png')
gf = f.convert('LA')
edge = gf.filter(ImageFilter.FIND_EDGES)
3. 白でフチ取りする
抽出した輪郭をもとに、白で縁取りをします。
フィルタをかけた後のアルファチャンネルのみ活用し、色は全部白で塗ったものを用意しました。
l, a = edge.split()
_l, _a = np.full_like(a, 255), np.array(a)
img_array = np.stack([_l, _a], 2)
border = Image.fromarray(np.uint8(img_array), "LA")
ただ、残念ながらこのまま重ねても元画像と重なってしまいほとんど縁が見えません。
border_color = border.convert('RGBA')
res = Image.alpha_composite(border_color, f)
そのため、輪郭を太らせて重ねます。一般的にはモルフォロジー変換で行うことができるようです。
PillowではこのOpenCVにあるようなメソッドはありませんが、MaxFilterを使うことで同等のことができます。理由については、こちらの記事が詳しいです。
ただ、やっていることはあまり変わりませんが、今回はずらして重ねるという力技の方が見た目が良かったので、そちらを採用しました。
border_color = border.convert('RGBA')
diff = [-border_size, border_size]
res = f
for xd in range(-border_size, border_size + 1):
for yd in range(-border_size, border_size + 1):
b = border_color.rotate(0, translate=(xd, yd))
res = Image.alpha_composite(b, res)
4. 微調整
平滑化
輪郭検出後の画像に平滑化フィルタ(ImageFilter.SMOOTH
)をかけてあげると、少し綺麗になります。
(左が平滑化しない場合、右がした場合)
アニメーションGIFの対応
このままだと、アニメGIFを読み込んだ際にエラーで落ちてしまうのでついでに対応してみます。
アニメGIFの場合、各フレームをImageSequence.Iterator
やImageSequence.all_frames
で取得することができます。
f = Image.open(file)
duration, loop = f.info.get('duration', 0), f.info.get('loop', 0)
frames = []
for frame in ImageSequence.Iterator(f):
bf = make_border(frame.convert('RGBA'), BORDER_SIZE)
frames.append(bf)
if len(frames) > 1:
frames[0].save(OUTPUT_DIR / file.name, save_all=True,
append_images=frames[1:], optimize=False, duration=duration, loop=loop, transparency=255, disposal=2)
else:
frames[0].save(OUTPUT_DIR / file.name)
これでおおよそのイメージはうまくいきますが、どうやらアニメGIFの読み書き周りはハマりどころが多く、透明色がパレットの255番じゃなかったりして正常に処理できない画像も一部ありました。
5. 置き換えるには?
Slackの絵文字は同じ名前で後から差し替えることができないので、削除->追加の手順を取る必要があります。
APIはEnterprise Gridのみ
カスタム絵文字の登録・削除に関するAPIは、実は提供自体はされています。しかし、これらはEnterprise Gridプランでのみ使うことができるので、おそらく使えない人が多いのではないかと思います。
非公開API or 管理画面からなんとかする
公式APIが使えない場合、管理画面から頑張って登録するか、管理画面で呼んでいるAPIを叩いてあげるしかなさそうです。
非公開APIのため、Qiitaでは触れませんが、一括処理できるスクリプトをリポジトリ内に置いたので、そちらをご覧ください。
ちなみに、一括登録のツールとしてはNeutral Face Emoji Toolsが有名ですが、大量の絵文字を登録しようとするとすぐレートリミットに引っかかるのでご注意ください。
まとめ
今回行った画像処理はごくごく簡単なものですが、意外とアニメGIFの処理やSlack側との出し入れに戸惑いました。
このツールで皆さんも快適にダークモードライフをお過ごしください👍