pyqtgraphでヒートマップを作成する

# はじめに
　リアルタイムでヒートマップを作成する機会があり、matplotlibでは処理速度が不安だった為pyqtgraphを使用しました。しかしpyqtgraphは日本語の情報が少なく、ヒートマップに使うカラーバーもデフォルトでは無かった為色々調べた結果を記載します。

# 環境
Mac OS
Python 3.8.1

pipでインストールしたもの

colour          0.1.5
numpy           1.19.2
pgcolorbar      1.0.0
PyQt5           5.15.2
PyQt5-sip       12.8.1
pyqtgraph       0.11.0

`pip install colour numpy pgcolorbar PyQt5 PyQt5-sip pyqtgraph`

# pgcolorbar
pyqtgraphでカラーバーを作成してくれるライブラリです。
pipでインストールした後下記コマンドでdemoが見れます。

`pgcolorbar_demo`


# 詳細
リアルタイム描画は今回見送りしてヒートマップのみの説明をします。
最終的に以下の画面を作成します。
(2021/01/19 修正前)
<img width=300 alt='heatmap.gif' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/be9da312-6403-75bd-1293-72fc52ce1ce9.gif'>

## サンプルデータ
説明に使用するサンプルデータです。色々やってますが特に意味はありません。
2次元配列の為RGB情報はありません。

```python
# サンプル配列
data = np.random.normal(size=(200, 200))
data[40:80, 40:120] += 4
data = pg.gaussianFilter(data, (15, 15))
data += np.random.normal(size=(200, 200)) * 0.1

```

## 画像表示の最小コード(とする)
以降は下記コードに追記してヒートマップを作成していきます。
行っていることは

1. ウィンドウ作成
2. 画像作成
3. 画像をビューに追加
4. グラフを作成(画像の座標表示の為)
5. グラフに3.で作成したビューを追加
6. ウィンドウにグラフを追加
7. 表示

です

```python
"""pyqtgraphで画像を表示する最小コード(とする)"""
import sys

import numpy as np
from PyQt5 import QtWidgets
import pyqtgraph as pg


# サンプルデータのコードは省略

# GUIの制御オブジェクト
app = QtWidgets.QApplication(sys.argv)
# ウィンドウ作成
window = pg.GraphicsLayoutWidget()
# 画像オブジェクト作成 & 画像をセット
image = pg.ImageItem()
image.setImage(data)
# 画像を格納するボックス作成 & 画像オブジェクトをセット
view_box = pg.ViewBox()
view_box.addItem(image)
# プロットオブジェクト作成 & 上で作成したview_boxをセット
plot = pg.PlotItem(viewBox=view_box)
# ウィンドウにplotを追加
window.addItem(plot)
# ウィンドウ表示
window.show()
# プログラム終了
sys.exit(app.exec_())

```
この状態で実行すると以下の画面が作成されます。

<img width=300 alt='minimum.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/6cba0248-6a34-65d1-847f-91ff39b0d1b9.png'>


## ViewBox
以下を追加します。

```python
view_box.setAspectLocked(lock=True)
```
view_boxのアスペクト比を固定します。画像サイズが200×200の正方形の為、ウィンドウサイズが変わった時に形が変わってほしくないからです。

追加した周辺のコード

```python
view_box = pg.ViewBox()
view_box.setAspectLocked(lock=True)  # Add
view_box.addItem(image)
```
実行した時の画面
<img width=300 alt='seihoukei.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/c77e2308-de68-73a7-6401-86f60b71a1e6.png'>

## PlotItem
編集する個所はありません。

## ImageItem
追記した箇所とその周辺を下記に記載します。

```python
################## Add #######################
from colour import Color

blue, red = Color('blue'), Color('red')
colors = blue.range_to(red, 256)
colors_array = np.array([np.array(color.get_rgb()) * 255 for color in colors])
look_up_table = colors_array.astype(np.uint8)
################## Add #######################

image = pg.ImageItem()
image.setLookupTable(look_up_table)  # Add
image.setImage(data)
```
実行した時の画面
<img width=300 alt='lookuptable.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/ee0f4e5e-7700-5dde-1803-b59ece5942c2.png'>

### from colour import Color
中身の詳細を記載します。この項で作成したいものはRGB情報の2次元配列(符号なし8bit整数)です
[[R, G, B],
 [R, G, B],
 ...
 [R, G, B],]

```python
blue, red = Color('blue'), Color('red')
# >>> <class 'colour.Color'> <class 'colour.Color'>

blue.get_rgb()
# >>> (0.0, 0.0, 1.0)

colors = blue.range_to(red, 256) #  青から赤までの色を256分割
# >>> generator リストにして中身を見ると
# >>> [<Color blue>, <Color #0004ff>, <Color #0008ff>, ..., <Color red>]

colors_array = np.array([np.array(color.get_rgb()) * 255 for color in colors])
# >>> [[  0.   0. 255.]
#      [  0.   4. 255.]
#      [  0.   8. 255.]
#      ...
#      [255.   0.   0.]] float64

look_up_table = colors_array.astype(np.uint8) # 符号なし8bit整数に変換
# >>> [[  0   0 255]
#      [  0   3 255]
#      ...
#      [255   0   0]] uint8
```

### Look Up Table
> 入力色データに対応する出力色データをLook Up(参照)するTable(対応表)
> [第一回　LUTの基本 | TVLogic](http://ismini.tvlogic.tv/jp/technology/lutindex.html)

らしいです。(あまり詳しくなくてすみません、、、)
サンプルデータの値をRGB情報に変換してくれる表のようなイメージです。

### 2021/01/19 追記

```python
image.setOpts(axisOrder='row-major')
```
上記コードを追加して下さい。
`pg.ImageItem`はデフォルトで列 -> 行で２次元配列を読み込みます。
２次元配列は基本的に行列なので行 -> 列で読み込む為に必要です。
書かなかった場合、元データの転置行列のように読み込まれます。

修正後のコード

```python
################## Add #######################
from colour import Color

blue, red = Color('blue'), Color('red')
colors = blue.range_to(red, 256)
colors_array = np.array([np.array(color.get_rgb()) * 255 for color in colors])
look_up_table = colors_array.astype(np.uint8)
################## Add #######################

image = pg.ImageItem()
image.setOpts(axisOrder='row-major')  # 2021/01/19 Add
image.setLookupTable(look_up_table)  # Add
image.setImage(data)
```

## ColorLegendItem
カラーバーを作成します
以下を追加します。

```python
################## Add #######################
from pgcolorbar.colorlegend import ColorLegendItem

color_bar = ColorLegendItem(imageItem=image, showHistogram=True)
color_bar.resetColorLevels()

window.addItem(color_bar)
################## Add #######################
```

`imgeItem`には`pg.imgItem`を渡します。`showHistogram`はカラーバー横のヒストグラムを表示するかを決められます。デフォルトはTrueです。

`resetColorLevels()`でカラーバーの範囲をデータの最大、最小値に設定します。

実行した画面(2021/01/19 修正前)
<img width=300 alt='colorbar.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/6a3607f4-a970-908a-90b6-c1a62f91ddbd.png'>

### 2021/01/20 追記
`label`引数でカラーバーのラベルを設定できます。

```python
color_bar = ColorLegendItem(imageItem=self.image, showHistogram=True, label='sample')
```

# 全体
全体のコード

```python
"""全体コード"""
import sys

from colour import Color
import numpy as np
from pgcolorbar.colorlegend import ColorLegendItem
from PyQt5 import QtWidgets
import pyqtgraph as pg

# サンプル配列
data = np.random.normal(size=(200, 200))
data[40:80, 40:120] += 4
data = pg.gaussianFilter(data, (15, 15))
data += np.random.normal(size=(200, 200)) * 0.1

app = QtWidgets.QApplication(sys.argv)

window = pg.GraphicsLayoutWidget()

blue, red = Color('blue'), Color('red')
colors = blue.range_to(red, 256)
colors_array = np.array([np.array(color.get_rgb()) * 255 for color in colors])
look_up_table = colors_array.astype(np.uint8)

image = pg.ImageItem()
image.setOpts(axisOrder='row-major')  # 2021/01/19 Add
image.setLookupTable(look_up_table)
image.setImage(data)

view_box = pg.ViewBox()
view_box.setAspectLocked(lock=True)
view_box.addItem(image)

plot = pg.PlotItem(viewBox=view_box)

color_bar = ColorLegendItem(imageItem=image, showHistogram=True, label='sample')  # 2021/01/20 add label
color_bar.resetColorLevels()

window.addItem(plot)
window.addItem(color_bar)

window.show()

sys.exit(app.exec_())

```

# 2021/02/04 追記

色々触ってみて最終的にこんな感じになったので記載します

2021/02/05
関数名を少し変えた & `def current_image`でNoneが返ってくることがあるのでその表記を追加

- `set_first_image` -> `set_image`
- `set_image` -> `update_image`
- `disable_move_bar` -> `set_mouse_enabled_bar`(TrueとFalseの関係が逆になってた)
- `reset_range_bar` -> `reset_color_levels`

```python
from typing import Optional

from colour import Color
import numpy as np
from PyQt5 import QtWidgets
import pyqtgraph as pg
from pgcolorbar.colorlegend import ColorLegendItem


class HeatMapWidget(QtWidgets.QGraphicsWidget):
    """ヒートマップウィジット

    Attributes
    ----------
    image: pg.ImageItem
        ヒートマップで表示する画像
    view_box: pg.ViewBox
        画像表示用ボックス
    plot: pg.PlotItem
        imageの画像サイズを表示するプロット
    color_bar: ColorLegendItem
        カラーバー
    layout: QtWidgets.QGraphicsGridLayout
        plotとcolor_barを設置するレイアウト
    """

    def __init__(self, title: str = '', bar_label: str = '') -> None:
        """コンストラクタ

        Parameters
        ----------
        title: str default=''
            グラフタイトル
        bar_label: str default=''
            カラーバーラベル
        """
        super(HeatMapWidget, self).__init__()

        self.image = pg.ImageItem(axisOrder='row-major')
        self.image.setLookupTable(self.make_lookup_table())

        self.view_box = pg.ViewBox(lockAspect=True)
        self.view_box.addItem(self.image)

        self.plot = pg.PlotItem(viewBox=self.view_box)
        self.plot.setTitle(title)
        self.plot.setLabels(bottom='X', left='Y')

        self.color_bar = ColorLegendItem(imageItem=self.image,
                                         label=bar_label)

        self.layout = QtWidgets.QGraphicsGridLayout()
        self.layout.setContentsMargins(1, 1, 1, 1)
        self.layout.setSpacing(0)
        self.layout.addItem(self.plot, 0, 0)
        self.layout.addItem(self.color_bar, 0, 1)
        self.setLayout(self.layout)

        # 画像サイズ最適化ボタン(左下の小さい奴)が押された時, ついでにカラーバーの範囲も最適化する
        self.plot.autoBtn.clicked.connect(self.color_bar.resetColorLevels)

    @property
    def current_image(self) -> Optional[np.ndarray]:
        """表示中の画像配列を返す
        画像が入っていない場合Noneを返す

        Returns
        ----------
        image.image: np.ndarray
            表示画像
        """
        return self.image.image

    def set_image(self, image: np.ndarray) -> None:
        """画像を挿入する. 1回目の挿入を想定している為,
        画像サイズの最適化 & カラーバーの範囲を画像データに合わせる

        Parameters
        ----------
        image: np.ndarray
            表示したい画像配列
        """
        self.image.setImage(image)
        self.plot.autoBtnClicked()
        self.color_bar.resetColorLevels()

    def update_image(self, image: np.ndarray) -> None:
        """画像を更新する.

        Parameters
        ----------
        image: np.ndarray
            表示したい画像配列
        """
        self.image.updateImage(image)

    def set_lower_level(self, lower: float) -> None:
        """カラーバーの最小値を設定する

        Parameters
        ----------
        lower: float
            設定したい最小値
        """
        self._set_levels(lower, None)

    def set_upper_level(self, upper: float) -> None:
        """カラーバーの最大値を設定する

        Parameters
        ----------
        upper: float
            設定したい最大値
        """
        self._set_levels(None, upper)

    def _set_levels(self, new_lower: Optional[float],
                    new_upper: Optional[float]) -> None:
        """ カラーバーの範囲を設定する.
        pgcolorbar_demoのプログラムを参考

        - 新しい下限値が今の上限値より大きい時(new_lower > old_upper)
            新しい下限値はそのままにして, 上限値をnew下限 +1する
        - 新しい上限値が今の下限値より小さい時(new_upper < old_lower)
            新しい上限値はそのままにして, 下限値をnew上限 -1する

        Parameters
        ----------
        new_lower: Optional[float]
            設定したいカラーバーの最小値
        new_upper: Optional[float]
            設定したいカラーバーの最大値
        """
        old_lower, old_upper = self.color_bar.getLevels()

        if new_lower is None:
            new_lower = old_lower
            if new_upper <= new_lower:
                new_lower = new_upper - 1

        if new_upper is None:
            new_upper = old_upper
            if new_upper <= new_lower:
                new_upper = new_lower + 1

        self.color_bar.setLevels((new_lower, new_upper))

    def reset_color_levels(self) -> None:
        """カラーバーの表示値範囲を画像データと合わせる.
        カラーバーの操作は有効に戻す.

        See Also
        ----------
        set_mouse_enabled_bar
        """
        self.set_mouse_enabled_bar(True)
        self.color_bar.resetColorLevels()

    def set_mouse_enabled_bar(self, flag: bool) -> None:
        """マウスからのカラーバー操作を有効/無効にする.

        Parameters
        ----------
        flag: bool
            有効/無効を決めるフラグ
            Trueの時有効にする
        """
        self.color_bar.overlayViewBox.setMouseEnabled(y=flag)
        self.color_bar.histViewBox.setMouseEnabled(y=flag)

    @staticmethod
    def make_lookup_table(lower_c: str = 'blue',
                          upper_c: str = 'red') -> np.ndarray:
        """ルックアップテーブル作成

        Parameters
        ----------
        lower_c: str default='blue'
            カラーバー下限値の色, 16進数表記も可能 ex) '#0000ff'
        upper_c: str default='red'
            カラーバー上限値の色, 16進数表記も可能 ex) '#ff0000'

        Returns
        ----------
        lookup_table: np.ndarray
            カラーバーの色を指定するテーブル
        """
        lower_color, upper_color = Color(lower_c), Color(upper_c)
        colors = lower_color.range_to(upper_color, 256)

        table = np.array([color.get_rgb() for color in colors]) * 255
        lookup_table = table.astype(np.uint8)

        return lookup_table

```

## 追記使用例
1枚だけ画像を入れています

<img width=500 alt='スクリーンショット 2021-02-05 131431.png' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/779817/7328523c-1803-1d8c-3440-7528b0be448d.png'>



```python
if __name__ == '__main__':
    import sys
    import numpy as np

    image1 = np.random.randint(0, 99, (50, 50))

    app = QtWidgets.QApplication(sys.argv)
    win = pg.GraphicsLayoutWidget()

    hm1 = HeatMapWidget()  # これ
    hm2 = HeatMapWidget()  # これ
    hm3 = HeatMapWidget()  # これ
    hm4 = HeatMapWidget()  # これ

    hm1.set_image(image1)  # hm1だけ画像追加

    # 追加して使う
    win.addItem(hm1)
    win.addItem(hm2)
    win.nextRow()
    win.addItem(hm3)
    win.addItem(hm4)

    win.show()
    sys.exit(app.exec_())

```

# 参考
[GitHub - titusjan/pgcolorbar: Color bar to use in PyQtGraph plots](https://github.com/titusjan/pgcolorbar)