ダークモードでもSlack絵文字をちゃんと見たい！

Slackのカスタム絵文字、楽しいですよね。
さて、ダークモードで使ってる方ならあるあるかもしれませんが、ダークモードにしてしまうとこんな問題が発生します。

![ダークモードで絵文字一覧を見た図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/ae77c660-70c2-e4f7-0279-523e74f28b6b.png)

＿人人人人人人人人人人＿
＞　絵文字が見えない　＜
￣Y^Y^Y^Y^Y^Y^Y^Y^Y￣

絵文字の背景を[透明ではなく白にすることで解決](https://tech.jxpress.net/entry/slack-dark-emoji)してらっしゃった記事もありましたが、今回は上の画像で黄色く囲んだ「オッ」のように、**縁取りをする方法**で解決したいと思います。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/12f8dbc5-59bb-8d3a-feb5-c96997bae2f6.png" width="500px">


今回作成したソースコードは、以下のリポジトリに置いてあります。是非使ってみてください！

<a href="https://github.com/dora1998/edged-emoji-maker"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/dora1998/edged-emoji-maker.png" width="460px"></a>

# 1. 絵文字を取り込む

まずは、Slackから絵文字画像を取ってきます。これは比較的簡単で、[`emoji.list` API](https://api.slack.com/methods/emoji.list)を使うと取得することができます。

`emoji:read` のスコープを設定したアプリケーションを作成してトークンを取得したら、以下のアドレスを叩くとJSONが得られます。

```
https://slack.com/api/emoji.list?token=xoxb-...
```

```json
{
    "ok": true,
    "emoji": {
        "bowtie": "https:\/\/emoji.slack-edge.com\/xxxxxx\/bowtie\/yyyyyy.png",
        "squirrel": "https:\/\/emoji.slack-edge.com\/xxxxxx\/squirrel\/zzzzzz.png",
    }
}
```

これをもとにPythonでシュシュっと取ってくるプログラムを書きます。

```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. フチを抽出する

フチの抽出には[ラプラシアンフィルタ](https://algorithm.joho.info/image-processing/laplacian-filter/)を使います。
今回は画像処理全般をPillowで行いました。当初、OpenCVも試しましたが、一部絵文字のアルファチャンネルが正常に読み込めなかったので諦めました。

```python
f = Image.open('hoge.png')
gf = f.convert('LA')
edge = gf.filter(ImageFilter.FIND_EDGES)
```

![ラプラシアンフィルタの適用例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/be7d0587-b50a-2f1e-6424-2e391dba10d7.png)
いい感じですね〜

# 3. 白でフチ取りする

抽出した輪郭をもとに、白で縁取りをします。
フィルタをかけた後のアルファチャンネルのみ活用し、色は全部白で塗ったものを用意しました。

```python
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")
```

ただ、残念ながらこのまま重ねても元画像と重なってしまいほとんど縁が見えません。

```python
border_color = border.convert('RGBA')
res = Image.alpha_composite(border_color, f)
```

![見えない](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/ea726f0a-c2f4-ca31-38ba-9c93de4b9147.png)

そのため、輪郭を太らせて重ねます。一般的には[モルフォロジー変換](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_morphological_ops/py_morphological_ops.html)で行うことができるようです。
PillowではこのOpenCVにあるようなメソッドはありませんが、[MaxFilterを使う](https://stackoverflow.com/questions/44195007/equivalents-to-opencvs-erode-and-dilate-in-pil)ことで同等のことができます。理由については、[こちらの記事](https://blog.shikoan.com/morphological-transformations-maxpool/)が詳しいです。

ただ、やっていることはあまり変わりませんが、今回はずらして重ねるという力技の方が見た目が良かったので、そちらを採用しました。

```python
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)
```

![フチを重ねた絵文字](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/3efe5fba-324d-27b2-6925-5eb126ca542e.png)

# 4. 微調整

## 平滑化

輪郭検出後の画像に平滑化フィルタ(`ImageFilter.SMOOTH`)をかけてあげると、少し綺麗になります。
（左が平滑化しない場合、右がした場合）

![平滑化前後の比較](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51163/57029bc3-0269-77d4-4599-5dfc98e67261.png)

## アニメーションGIFの対応

このままだと、アニメGIFを読み込んだ際にエラーで落ちてしまうのでついでに対応してみます。
アニメGIFの場合、各フレームを`ImageSequence.Iterator`や`ImageSequence.all_frames`で取得することができます。

```python
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は、実は[提供自体はされています](https://api.slack.com/methods/admin.emoji.add)。しかし、これらはEnterprise Gridプランでのみ使うことができるので、おそらく使えない人が多いのではないかと思います。

## 非公開API or 管理画面からなんとかする

公式APIが使えない場合、管理画面から頑張って登録するか、管理画面で呼んでいるAPIを叩いてあげるしかなさそうです。
非公開APIのため、Qiitaでは触れませんが、一括処理できるスクリプトをリポジトリ内に置いたので、そちらをご覧ください。

ちなみに、一括登録のツールとしては[Neutral Face Emoji Tools](https://chrome.google.com/webstore/detail/neutral-face-emoji-tools/anchoacphlfbdomdlomnbbfhcmcdmjej)が有名ですが、大量の絵文字を登録しようとするとすぐレートリミットに引っかかるのでご注意ください。

## まとめ

今回行った画像処理はごくごく簡単なものですが、意外とアニメGIFの処理やSlack側との出し入れに戸惑いました。
このツールで皆さんも快適にダークモードライフをお過ごしください👍