MIPカラー反射型液晶モジュールをRaspberry Pi Zeroで使う

# はじめに

屋外で使うのに適した、JDI製[バックライト付き 超低消費電力 MIPカラー反射型液晶モジュール 2.7型](https://www.switch-science.com/catalog/5395/)(以下MIP液晶と記載)をRasppberry Piで使えるようにしました。

2019/4発売であまり情報がない本製品ですが、mbedのフォーラムでJDIの方にいろいろご教示頂き、使えるようになりました。感謝を申し上げます。
価格だけがネックですが、素晴らしいディスプレイなので、本記事が情報源になれば思い、記事を作成しました。Pythonで記載したコードは本稿の最後と[github](https://github.com/hishizuka/py_mip_sample)にあります。

![mip.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/3d7ee1ee-4c27-2e55-cb0a-6a71e228fa4f.png)

# TFT液晶の弱点 (屋外使用時)

[サイクルコンピューターをガチで作ってみたら、割とできてしまったという話](https://qiita.com/hishi/items/8f77fd4655027fac7c7d)では、大きな反響を頂きありがとうございます。
この記事でご紹介した液晶モジュールのPiTFTですが、サイクルコンピューターで使う場合、TFT液晶であるがゆえに下記の弱点があり、長らく課題として認識していました。

- 晴天下の明るい環境で見えない。
 - バックライトの明るさを最大にしても。
- ゆえに一番の電力食いパーツ

どういうことかというと、下記の比較画像をご覧ください。わざと直射日光を当てる条件で撮影しました。
![comp-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/562656a2-9d68-569f-03e1-26c3f46bdf56.png)
左は、バックライトの明るさを最大した状態のPiTFT。真っ暗で全く見えません…
中央は、e-inkの[PaPiRus ePaper/eInk スクリーンハット](https://uk.pi-supply.com/products/papirus-epaper-eink-screen-hat-for-raspberry-pi?lang=ja)。視認性良好です。こちらも実は対応を進めています。
右は、市販品の[Pioneer SGX-CA600](http://pioneer-cyclesports.com/jp/products/cyclocomputer/sgx-ca600.html)。視認性良好です。

![comp-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/48fc552e-6d99-f43c-2bf4-8fea381002fb.png)
直射日光に当たってしまったPiTFTは、普段は手で影を作らないと見ることができず、問題意識を持っていました。
(画面真ん中のケイデンス255はバグですので、お気になさらぬよう…)

![comp-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/7c2cceb3-0a38-4aa6-2e49-a85630bf9745.png)
左を本件のMIP液晶に変えました。バックライトはつけていません。抜群の視認性です。

参考までにM5Stack。
PiTFTと同じILI9341ベースのTFT液晶なので、屋外用途には適しません。一時期、サイクルコンピュータの候補ハードとしていましたが、やめました。
![aaa.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/7a4c8f2e-7ce5-c1ea-b7d6-767bd4fce315.png)


先ほどと同じく、手で影を作らないと見えません。
![m5tstack-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100741/a0aa9250-0ada-05d1-9700-6cb1dc960614.png)


視認性以外の点も含め、汎用のTFT液晶モジュールと比べた時のメリット、デメリットをまとめますと、

**メリット**

- 直射日光下で本当に視認性が良い
 - 明るい環境ではバックライトは不要
- 消費電力が極めて小さい
 - （消費電流は近日中にきちんと測定して公開します）
- バッテリーを別途用意している環境では、バッテリーの容量削減ができ、小型化に貢献

**デメリット**

- お値段が¥19,000近くし、はっきり言って高い
- フルカラーではなく、8色しか使えない
 - RGBの赤緑青がそれぞれ0か1しか表現できず、2通り×3色で8色
- タッチパネルはなく、ボタンが別途必要
- 描画速度は高速ではない
 - 2.7インチのカタログスペックは6.5fps、現時点のプログラムが10fps弱
- Raspberry Piの専用品ではなく、接続が若干煩雑

デメリットが多いと感じるかもしれませんが、私のように外で使う人間にとっては、上記デメリットや他のTFTの選択を全て吹き飛ばしてしまう良さがあります。

個人的にはpHAT化した製品が出ないかなと、切望しています。

# 必要なもの

現在、スイッチサイエンスさんのみのお取り扱いです。

- [バックライト付き 超低消費電力 MIPカラー反射型液晶モジュール 2.7型 LPM027M128C](https://www.switch-science.com/catalog/5395/) (¥9,288)
 - バックライトなし版や、1.27インチ版もありますので、お好みで。
 - 互換品で、AZUMO LPM027M128B(2.7インチで\$54弱)、または4.4インチのAZUMO LPM044M141A(\$95弱)あり。
- [MIP反射型液晶中継ボード](https://www.switch-science.com/catalog/5393/) (¥9,720)
 - バックライトの制御なしでよければ、[ESPr® Developer用カラー反射型LCDシールド](https://www.switch-science.com/catalog/2874/)でも描画できる可能性があります。スイッチサイエンスさんにお問い合わせください。お値段あまり変わらず、小型化以外のメリットがありませんが…
 - Adafruit SHARP Memory Display Breakout(2.7インチ)からモノクロ液晶を取り外して使用可。2.7インチで$50弱。1.3インチのボードでも動く可能性あり。ただし、バックライトの接続コネクタはないことに注意。

以下は必要に応じて、適宜揃えてください。

- [FRDM-K64F](https://www.switch-science.com/catalog/1689/) (¥6,210)
 - [mbedのサンプルプログラム群](https://os.mbed.com/teams/JapanDisplayInc/code/)を一式見るなら必要
- ジャンパピン(メス-メス)
 - MIP反射型液晶中継ボードはメスのジャンパピンで接続します。

# 接続

mbed内にある本製品のフォーラムページ
https://os.mbed.com/questions/85441/Is-LPM013M126A-worked-by-python-in-raspb/
より引用します。

**画面周りの接続(SPI接続)**
[回路図](https://docid81hrs3j1.cloudfront.net/medialibrary/2019/03/JDI_MIP_LCD.pdf)または[別の説明ページ](https://os.mbed.com/teams/JapanDisplayInc/code/MIP8f_FRDM_sample/)より、中継ボード側のGNDはCN1-4です。
回路図をよく見ると、SPI接続に関しては両端にあるCN1～CN4を使わず、中央にあるCN14で代用可能です。
**SCSの接続はIO15(GPIO3)からIO16(GPIO4)に変更しています**
<img src="https://os.mbed.com/media/uploads/STakayama/ruspi3_mip_ifboard.png" />
**バックライト**
<img src="https://os.mbed.com/media/uploads/STakayama/bl_ruspi.png" />

# 描画プログラム

mbed内にある本製品のフォーラムページ
https://os.mbed.com/questions/85441/Is-LPM013M126A-worked-by-python-in-raspb/
でご教示いただいたコードを改良しました。

何を行っているかは、本製品の仕様書を熟読する必要があります。
(といっても、私もそれほど理解できていませんが…)
https://www.j-display.com/product/pdf/Leaflet/3LL_2.7_rectagular_BL_LPM027M128C.pdf
https://www.j-display.com/product/pdf/Datasheet/3LPM027M128C_specification_ver02.pdf

末尾にコードを掲載していますが、pil_to_screenメソッドに該当します。

## ざっくりとした方針

MIP液晶の解像度(横400ピクセル×高さ240ピクセル)に一致する画像を、都度転送する方法を考えます。
(今回は丸や矩形を描画することは考えません)

元ネタの画像はアプリケーションのスクリーンショット機能等で撮ってください。
QtのQWidgetなら、grabメソッドで取得できます。これをpaintEventメソッドに紐付けておけば、描画が発生する度にスクリーンショットが取得できます。何らかの処理を加えてPillowで扱える形式(縦×横×RGBの3次元配列)に変換してください。
本ページのサンプルプログラムは、横400ピクセル×高さ240ピクセルのBMPファイルをPillowで読み取るところから始めます。

### 高速化も意識
画像を配列化すると、横400ピクセル×高さ240ピクセル×RGB3色の288,000要素にも達するため、Pythonでfor文を書いてRaspberry Pi Zeroで処理をさせると、あまりの遅さに泣きを見ることになります。

方針は2つあり、NumPyなりCythonでPythonのfor文を避けること、また行単位の転送ができるので更新された行のみに転送対象を絞ることです。

最初は下記の読込～変換処理で3秒以上、転送処理で0.3秒近くかかっていましたが、NumPyによる高速化の結果、読込～変換処理(と差分行の検出)に0.03秒、転送処理が最大0.1秒弱まで行きました。転送処理は差分行の更新にするとさらに速くなるため、静止画を表示し続けるには十分な速度です。

サンプルプログラムはさらにSPIの動作周波数をオーバークロックしているので、自己責任のもと、お使いください。スペックシートではMax refresh frequencyは6.5Hz、フォーラムのプログラムはSPIの動作周波数は2MHzの指定がありますが、サンプルプログラムはこちらの環境で確認できた最大の値を入れています。

## 画像の読み込みと減色

Pillowを使って読み込み、

- カラーで表示するならRGBそれぞれを0か1にした8色に減色
 - RGBはそれぞれ0～255までの値をとれるので、中間の128を閾値に0か1にするロジックにしています
- 白黒にするなら白か黒の2値化を行います。

元の画像も色数を意識したものを用意したほうがいいでしょう。

## 転送形式への変換

仕様書によると、6通りの方法があります。

**(SINGLE or MULTIPLE) × (1bit, 3bit, 4bit) UPDATE MODE**

**1項目**

- SINGLE：特定の行
- MULTIPLE：複数行

SINGLEとMULTIPLEの違いはあまり意識する必要がなく、1行分のSINGLEを連続して送ればMULTIPLEになります。
SINGLEのデータの頭に別途2byteを付け加える必要があり、
先頭1byteに転送モード(1bit：0x88、3bit：0x80、4bit：0x90)、2byte目に行の位置(0～239)を指定します。
 
**2項目**

- 1bit：白黒
 - 1bitで1ピクセルの白黒を表し、400bit(8で割って50bytesのデータ)を送信
- 3bit (0x80)：カラー
 - 3bitでRGB1ピクセルを表し、400ピクセル×3bit = 1200bit(8で割って150bytesのデータ)を送信
- 4bit (0x90)：カラー
 - 3bitでRGB1ピクセルを表し、その後ダミーの1bitを入れ、400ピクセル×4bit = 1600bit(8で割って200bytesのデータ)を送信

これらの違いは描画速度に表れ、[mbedのサンプルプログラム群](https://os.mbed.com/teams/JapanDisplayInc/code/)を試すに、白黒にできるなら1bitが速く、カラーは3bitが速いです。プログラムでは3bitで転送しています。

全画面を毎回送るのと遅くなるので、前回送信した画像と比較し、差分がある行を検出しています。

データを転送しませんが、白黒に画面を点滅させるblinkモード、色を反転させるinversionモードもあります。

## MIP液晶に転送

[spidev](https://github.com/doceme/py-spidev)を使います。kernelのドライバを使うので、`raspi-config`でSPIを有効にしてください。

サンプルプログラムでは1行ごとにPythonのfor文でxfer2関数を呼び出して送っていますが、これだと少し遅いので、作成したプログラムでは送信対象の行を全部NumPyのtobytes関数で1次元のリストにまとめ、xfer3関数を1回だけ呼び出して送っています。xfer3関数内では、C側でループを実行できるので、速度が若干向上します。

これ以上の速度向上を狙うなら、spidev内でSPI送受信に使われるioctl()を用いずに、レジスタを直接叩く手法を参考([fbcp-ili9341](https://github.com/juj/fbcp-ili9341)等)にするのがよさそうです。(pigpioで達成、後述)

余談ですが、差分行の更新では、なぜか1行だけ更新されない状態で残ることがあるので、最後に空のNO-UPDATE MODEを入れて更新をかけています。

# バックライト制御プログラム

バックライトはPiTFTでも用いられているPWMで制御していますが、仕組みをあまりわかっていません…
周波数の設定は60Hzではなく64Hzのほうがバックライトのチラツキがなぜか抑えられ、安定します。明るさbrightnessが100未満だと、CPU負荷に応じてチラツキはどうしてもでてしまうようです。

サンプルコードはPIN7(BCM4)で制御していますが、いろいろなpHAT等と繋ぐと競合することがあり(例えばEnviro pHATのLED)、適宜PIN16(BCM23)等に変更するなど、お使いの環境に応じて調整願います

掲載したコードのset_brightnessメソッドに該当します。

(2020/5/10追記)
GPIO.PWMではソフトウェアPWMとなり、I2Cセンサーと干渉する例が見受けられました。
具体的には、北海道で長さ数kmのトンネルを走り、バックライトを転倒させたら、気圧センサーの値が盛大にブレました。
pigpioのハードウェアPWMを使うと、安定します。(後述)

# コード

最新版は以下から取得ください。
https://github.com/hishizuka/py_mip_sample

## spidev版

```python:mips_test.py
import time
from PIL import Image
import numpy as np
import datetime

IS_RASPI = False
try:
    import spidev
    import RPi.GPIO as GPIO
    IS_RASPI = True
except:
   pass

print("SPI LOAD:", IS_RASPI)
print("##### PYTHON NIL #####")

DISP = 27
SCS = 23 #22
VCOMSEL = 17
BACKLIGHT = 4 #if GPIO 4(pin7) is already used with other devics, set 18(pin12) etc

#0x90 4bit update mode
#0x80 3bit update mode (fast)
#0x88 1bit update mode (most fast, but 2-color)
UPDATE_MODE = 0x80

SCREEN_WIDTH = 400
SCREEN_HEIGHT = 240


class MipDisplay():

    spi = None
    
    buff_width = int(SCREEN_WIDTH*3/8)+2 #for 3bit update mode
    #buff_width = int(SCREEN_WIDTH*4/8)+2 #for 4bit update mode

    def __init__(self):
      
        if not IS_RASPI:
            return
        
        self.spi = spidev.SpiDev()
        self.spi.open(0, 0)
        self.spi.mode = 0b00 #SPI MODE0
        #self.spi.max_speed_hz = 2000000 #MAX 2MHz
        self.spi.max_speed_hz =  9500000 #overclocking
        self.spi.no_cs 
        time.sleep(0.1)     #Wait
         
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(DISP, GPIO.OUT)
        GPIO.setup(SCS, GPIO.OUT)
        GPIO.setup(VCOMSEL, GPIO.OUT)
         
        GPIO.output(SCS, 0)     #1st=L
        GPIO.output(DISP, 1)    #1st=Display On
        #GPIO.output(DISP, 0)   #1st=No Display
        #GPIO.output(VCOMSEL, 0) #L=VCOM(1Hz)
        GPIO.output(VCOMSEL, 1) #L=VCOM(1Hz)
        time.sleep(0.1)

        GPIO.setup(BACKLIGHT, GPIO.OUT)
        #self.backlight = GPIO.PWM(BACKLIGHT, 60)
        self.backlight = GPIO.PWM(BACKLIGHT, 64)
        self.backlight.start(0)

        self.pre_img = np.zeros((SCREEN_HEIGHT, self.buff_width), dtype='uint8')
        self.img_buff_rgb8 = np.empty((SCREEN_HEIGHT, self.buff_width), dtype='uint8')
        self.img_buff_rgb8[:,0] = UPDATE_MODE 
        self.img_buff_rgb8[:,1] = np.arange(SCREEN_HEIGHT)
        self.img_buff_rgb8[:,0] = self.img_buff_rgb8[:,0] + (np.arange(SCREEN_HEIGHT) >> 8)

        self.clear()
    
    def clear(self):
        if not IS_RASPI:
            return
        GPIO.output(SCS, 1)
        time.sleep(0.000006)
        self.spi.xfer2([0b00100000,0]) # ALL CLEAR MODE
        GPIO.output(SCS, 0)
        time.sleep(0.000006)

    def no_update(self):
        if not IS_RASPI:
            return
        GPIO.output(SCS, 1)
        time.sleep(0.000006)
        self.spi.xfer2([0b00000000,0]) # NO UPDATE MODE
        GPIO.output(SCS, 0)
        time.sleep(0.000006)

    def blink(self, sec):
        if not IS_RASPI:
            return
        s = sec
        state = True
        interval = 0.5
        while s > 0:
            GPIO.output(SCS, 1)
            time.sleep(0.000006)
            if state:
                self.spi.xfer2([0b00010000,0]) # BLINK(BLACK) MODE
            else:
                self.spi.xfer2([0b00011000,0]) # BLINK(WHITE) MODE
            GPIO.output(SCS, 0)
            time.sleep(interval)
            s -= interval
            state = not state
        self.no_update()

    def inversion(self, sec):
        if not IS_RASPI:
            return
        s = sec
        state = True
        interval = 0.5
        while s > 0:
            GPIO.output(SCS, 1)
            time.sleep(0.000006)
            if state:
                self.spi.xfer2([0b00010100,0]) # INVERSION MODE
            else:
                self.no_update()
            GPIO.output(SCS, 0)
            time.sleep(interval)
            s -= interval
            state = not state
        self.no_update()

    #def pil_to_screen(self, pil_img):
    def pil_to_screen(self, img_file):

        im_array = np.array(Image.open(img_file))
        #im_array = np.array(pil_img)

        t = datetime.datetime.now()

        #3bit mode update
        self.img_buff_rgb8[:,2:] = np.packbits(
            ((im_array > 128).astype('uint8')).reshape(SCREEN_HEIGHT,SCREEN_WIDTH*3),
            axis=1
            )
        img_bytes = bytearray()

        #differential update
        rewrite_flag = False
        diff_lines = np.where(np.sum((self.img_buff_rgb8 == self.pre_img), axis=1) != self.buff_width)[0] 
        print("diff ", int(len(diff_lines)/SCREEN_HEIGHT*100), "%")
        img_bytes = self.img_buff_rgb8[diff_lines].tobytes()
        if len(diff_lines) > 0:
            rewrite_flag = True
        self.pre_img[diff_lines] = self.img_buff_rgb8[diff_lines]

        print("Loading images... :", (datetime.datetime.now()-t).total_seconds(),"sec")

        t = datetime.datetime.now()
        if IS_RASPI:
            GPIO.output(SCS, 1)
            time.sleep(0.000006)
            if len(img_bytes) > 0:
                self.spi.xfer3(img_bytes)
            #dummy output for ghost line
            self.spi.xfer2([0x00000000,0])
            time.sleep(0.000006)
            GPIO.output(SCS, 0)
        print("Drawing images... :", (datetime.datetime.now()-t).total_seconds(),"sec")

    def set_brightness(self, brightness):
      
        b = brightness
        if brightness >= 100:
            b = 100
        elif brightness <= 0:
            b = 0
        
        if not IS_RASPI:
            return
        self.backlight.ChangeDutyCycle(b)
        time.sleep(0.05)

    def backlight_blink(self):
        if not IS_RASPI:
            return
        for x in range(2):
            for pw in range(0,100,1):
                self.backlight.ChangeDutyCycle(pw)
                time.sleep(0.05)
            for pw in range(100,0,-1):
                self.backlight.ChangeDutyCycle(pw)
                time.sleep(0.05)

    def quit(self):
        if not IS_RASPI:
            return
        #self.clear()
        GPIO.output(DISP, 1)
        time.sleep(0.1)
        self.set_brightness(0)
        self.spi.close()
        GPIO.cleanup()

if __name__ == '__main__':
    m = MipDisplay() 
    m.set_brightness(50)
    m.pil_to_screen('img/004_blood3.bmp')
    time.sleep(1)
    m.pil_to_screen('img/004_blood3.bmp')
    time.sleep(1)
    m.pil_to_screen('img/005_blood4.bmp')
    time.sleep(1)
    m.pil_to_screen('img/n00011 400x240 navi.bmp')
    time.sleep(1)
    m.pil_to_screen('img/n00012 400x240 navi.bmp')
    time.sleep(1)
    m.pil_to_screen('img/003_b1.bmp')
    time.sleep(1)
    m.blink(3)
    m.inversion(3)
    m.quit()
```

### 実行結果

youtubeに動画をアップロードしました。
[![youtube](http://img.youtube.com/vi/c4KMGHixH8Y/0.jpg)](http://www.youtube.com/watch?v=c4KMGHixH8Y)


コンソールには下記が出ます。実行環境はRaspberry Pi Zero Wです。

- Loading imagesは画像の読み込みに相当し、一定して以下の時間がかかっています
 - 前回画像との比較で、0.03秒前後
- Drawing imagesはMIP液晶に転送に相当し、描画の行数に応じて変わります
 - 全画面の書き換え(diff 100%)は、0.06秒強
 - 一部の場合は、概ね割合に沿った時間

```
SPI LOAD: True
##### PYTHON NIL #####
diff  100 %
Loading images... : 0.031313 sec
Drawing images... : 0.063316 sec
diff  0 %
Loading images... : 0.029179 sec
Drawing images... : 0.000835 sec
diff  48 %
Loading images... : 0.029554 sec
Drawing images... : 0.030854 sec
diff  100 %
Loading images... : 0.029778 sec
Drawing images... : 0.06303 sec
diff  31 %
Loading images... : 0.029376 sec
Drawing images... : 0.019999 sec
diff  100 %
Loading images... : 0.029154 sec
Drawing images... : 0.063802 sec
```

## pigpio版 (2020/8/16追記)

バックライトの安定化、および、描画速度が向上します。
ハードウェアPWMを使うので、バックライトはGPIO4(pin7)ではなく、GPIO18(pin12)に変更してください。

なお、本機能のためにpigpiodをONにすると、もれなく
[「pigpioがCPUを浪費している」](https://qiita.com/genshibangou16/items/b577b03fe52d273b328f)で紹介されている問題に当たりますので、気になる人はアラートオフのオプションを導入しましょう。

[ソースコード](https://raw.githubusercontent.com/hishizuka/py_mip_sample/master/mip_test_pigpio.py)の貼り付けは割愛します。

実行時間は以下の通り2/3ほどになり、実行速度は1.5倍程度向上しています。

```
SPI LOAD: True
##### PYTHON NIL #####
diff  100 %
Loading images... : 0.030667 sec
Drawing images... : 0.038905 sec
diff  0 %
Loading images... : 0.028583 sec
Drawing images... : 0.003339 sec
diff  48 %
Loading images... : 0.0291 sec
Drawing images... : 0.021334 sec
diff  100 %
Loading images... : 0.02914 sec
Drawing images... : 0.037509 sec
diff  31 %
Loading images... : 0.028795 sec
Drawing images... : 0.014594 sec
diff  100 %
Loading images... : 0.029355 sec
Drawing images... : 0.037846 sec
```