Jupyter NotebookでエクスポートしたHTMLのファイルサイズを削減する

# まとめ
Jupyter NotebookでエクスポートしたHTMLファイルに対し、埋め込まれた画像ファイルの形式を変換することで、ファイルサイズを1/10程度に削減できます。画像ファイルはBase64エンコードされたPNG形式で、BeautifulSoupでパース・置換、OpenCVでエンコード（JPEG形式へ変換）できます。

# はじめに
画像認識アルゴリズムの実験結果を共有する際に、Jupyter NotebookのHTMLエクスポート機能が重宝しています。Jupyter Notebookを使うことでテキストデータや画像データの読み込み・整形・出力はもちろん、Confusion matrixやPR curveなどの性能評価指標のプロットも手軽にできます。加えて、エクスポートの形式をHTMLにすることで、ブラウザさえあれば誰でも閲覧できるようになり、幅広いステークホルダーと情報共有ができます。

しかしながら、ノートブックに含まれる画像の大きさ・数によっては、エクスポートしたHTMLファイルの容量が大きくなってしまいます。私の場合は、画像認識アルゴリズムで不正解だった画像データを並べて表示した結果、HTMLファイルの容量が数十MBになってしまうことが多いです。そのため、メッセンジャーやメールなどのコミュニケーションツールで送信するには憚られたり、添付容量制限に引っかかったりしてしまいます。

同様の問題で困っている人や解法を探してみましたが見つからなかったので、手間のかからない範囲で解決することにしました。

# HTML内の画像を圧縮する
Jupyter NotebookでエクスポートしたHTMLでは、Base64エンコード済みのPNG画像がHTMLファイル内に埋め込まれています。matplotlibで出力するグラフは、同じ画素値が連続する領域が広いため、PNG形式でもデータ容量が十分に圧縮できます。一方で、写真などの自然画像については、PNG形式では同様の圧縮効果は期待できません。これらの画像をJPEG形式にすることで、HTML全体の容量を削減することを考えます。

## ソースコード

HTMLファイルを文字列として受け取り、ファイル内のBase64エンコードされた画像をJPEG圧縮するクラスです。

```python
import base64
import re

import cv2.cv2 as cv2
import numpy as np
from bs4 import BeautifulSoup


class EmbedImageCompressor(object):
    _pattern = 'data:image/\w+;base64,(.+)'
    _pattern_compiled = re.compile(_pattern, re.S)
    _src_prefix = 'data:image/jpeg;base64,'

    def __init__(self, file_path):
        with open(file_path, 'r') as f:
            self.content = f.read()
        self.soup = BeautifulSoup(self.content, 'html.parser')

    def process(self):
        for tag in self.soup.findAll('img'):
            src_str = tag.attrs.get('src')
            result = self._pattern_compiled.match(src_str)
            if result is None:
                continue

            img_b64 = result.group(1)
            img_cv2 = decode(img_b64)
            img_b64_new = compress_and_encode(img_cv2)

            tag_new = self.soup.new_tag('img')
            tag_new.attrs['src'] = f'{self._src_prefix}{img_b64_new}'
            tag.replace_with(tag_new)

    def save(self, path: str):
        with open(path, 'w') as f:
            f.write(self.soup.prettify())


def compress_and_encode(img: np.ndarray, quality=95) -> str:
    encode_param = [cv2.IMWRITE_JPEG_QUALITY, quality]
    success, img_compressed = cv2.imencode('.jpg', img, encode_param)
    # TODO: handle fail of encoding appropriately
    if not success:
        raise ValueError

    img_b64 = base64.b64encode(img_compressed)
    return img_b64.decode('utf-8')


def decode(img_b64: str) -> np.ndarray:
    img_data = base64.b64decode(img_b64)
    img_np = np.frombuffer(img_data, np.uint8)
    img = cv2.imdecode(img_np, cv2.IMREAD_ANYCOLOR)
    return img
```

こんな感じで呼びます。

```python
from embed_image_compressor import EmbedImageCompressor

input_path = 'resources/cifar10_tutorial.html'
out_path = 'resources/out.html'


def main():
    compressor = EmbedImageCompressor(input_path)
    compressor.process()
    compressor.save(out_path)


if __name__ == '__main__':
    main()
```

## HTMLのパース
エクスポートしたHTMLの中身を見てみると、画像は次のように埋め込まれていることが確認できました。

```html
<img src="data:image/png;base64,{画像をbase64エンコードした文字列}">
```

したがって、正規表現などのパターンで `src` の部分を引っ掛けて置換すればよいことが分かります。HTMLのパースおよび正規表現のマッチングには標準モジュールのHTMLParserを使えば十分ですが、置換後のHTMLを出力する必要があり、この置換がより手軽にできそうなBeautifulSoupを採用しました。

```python
for tag in self.soup.findAll('img'):  # HTML内のimgタグに対してイテレート
    src_str = tag.attrs.get('src')  # imgタグ内のsrcプロパティを取得
    result = self._pattern_compiled.match(src_str)  # 正規表現マッチング
    
    # 中略。ここでsrcプロパティをデコード→変換→再エンコードする
    
    # 置換も楽チン
    tag_new = self.soup.new_tag('img')
    tag_new.attrs['src'] = f'{self._src_prefix}{img_b64_new}'
    tag.replace_with(tag_new)
```

## Base64エンコードされた画像のデコード・圧縮・再エンコード
画像のJPEG圧縮にはOpenCVを利用しました。理由は、単に使い慣れているためです。JPEG圧縮というタスクに対してはオーバースペックなライブラリですが、学習コストがかからないことを重視しました。

### Base64のデコード
この記事を参考にしました：[[Python3] 画像をBase64にエンコード、Base64をNumPy配列へ読み込みOpenCVで処理、NumPy配列をBase64に変換 - Qiita](https://qiita.com/happou/items/5b7072d145a974380787)

```python
def decode(img_b64: str) -> np.ndarray:
    img_data = base64.b64decode(img_b64)
    img_np = np.frombuffer(img_data, np.uint8)
    img = cv2.imdecode(img_np, cv2.IMREAD_ANYCOLOR)
    return img
```

### JPEG圧縮
この記事を参考にしました：[OpenCVを利用して画像を圧縮（encode→decode）する - Qiita](https://qiita.com/ka10ryu1/items/5fed6b4c8f29163d0d65)

`cv2.imencode` はエンコードの成否を返しますが、エンコードが失敗するケースが不明のためハンドリングが適当です。

```python
def compress_and_encode(img: np.ndarray, quality=95) -> str:
    encode_param = [cv2.IMWRITE_JPEG_QUALITY, quality]
    success, img_compressed = cv2.imencode('.jpg', img, encode_param)
    # TODO: handle fail of encoding appropriately
    if not success:
        raise ValueError

    img_b64 = base64.b64encode(img_compressed)
    return img_b64.base64_to_ndarray('utf-8')
```

# 留意点
- 変換前後で、タグのインデントなど、HTMLのフォーマットが変わることがあります。BeautifulSoupの使い方次第で解決できそうですが、HTMLの見た目には変化ないので手付かずになっています。
- `&nbsp;` など一部エンティティうまく扱えず、文字化けすることがあるようです。これも大きな問題になっていないので手付かずです。

# 処理結果
HTMLファイル内の画像枚数によりますが、私の場合は1/10程度の削減（数十MB→数MB）ができました。