カラーLEDパネルにRaspberry Pi Picoで画像ファイルを表示する (CircuitPython)

![IMG_4803.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986568/e6729dd8-29f9-409f-9347-c8d30e480617.jpeg)
街中でよく見かけるLEDパネルが個人でも1枚単位で入手できるようになって数年経ち、日に日に価格も安くなっていますが、タイトルのような最も単純なことをお手軽に(安価に)やろうとしたときに予想外にまとまった情報が少なかった(特に日本語)のと、Microsoft Copilotとかが例によって古い内容を答えてきたので、またもや【令和7年最新版】としてここに一旦まとめておきます。お手軽なのでCとかではなくCircuitPythonを使います。
というわけで高いRaspberry Piなどを使わないのでパネル込み時価5000円で遊べます。

表示するのは64x32ピクセルのbmp画像ファイルです[^practicality]。`HUB75`なので基本的にダイナミック点灯というか、古のCRTよろしく行ごとに走査しながら点灯させているので冒頭のデジカメ写真ではよくわからないかもしれませんが、直視ではこれよりはもう少し階調のわかる表示ができます。アイコン画像なども十分表示できるでしょう。
[^practicality]:実用性についてツッコんではいけない。
### Raspberry Pi Pico
純正品なら2でもWでもどれでも大丈夫だと思います。未確認ですがCircuitPythonが入れられるものなら互換品でも大丈夫だと思います。モデルに合ったCircuitPythonを入れておいてください。
### LEDパネルと接続方法
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986568/f0106e2a-88b0-4382-91cb-7f545bf8b1a1.jpeg" width="300" />
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2986568/b88a911c-3266-4d26-8b85-71db14a6a1a1.jpeg" width="300" />

最近Amazonなどでもよく見かける`HUB75`インタフェースのヤツです。自分は64x32ドット3mmピッチのものを3500円ぐらいで入手しました[^2_5mm]。ちょっと修正すれば64x64のやつでもいけるはず。
以下の通りRaspberry Pi Picoと接続します。電源も念のため別系統で3Aぐらいのものを用意しましたが、実際の消費電流は1A程度でした。
接続については他に情報がたくさんあるのでここではこれ以上詳細の説明はしません。
[^2_5mm]:2.5mmピッチのやつは倍ぐらいの値段するので却下。
```code.py
rgb_pins=[
    board.GP2,  # R1
    board.GP3,  # G1
    board.GP6,  # B1
    board.GP7,  # R2
    board.GP8,  # G2
    board.GP9,  # B2
],
addr_pins=[board.GP10, board.GP16, board.GP18, board.GP20],  # A  # B  # C  # D
clock_pin=board.GP11,
latch_pin=board.GP12,  # (STB)
output_enable_pin=board.GP13,
```
### 画像ファイル表示のミニマムなコード
必要なライブラリを`import`して最初に`displayio`を初期化します。

``` code.py
import gc
import board
import displayio
import rgbmatrix
import framebufferio
import time

displayio.release_displays()
...
```
つぎに先ほどのピン接続で`rgbmatrix`を設定します。
カラーのビット深度は一応`framebuffer`の`RGB565`をカバーする`6`(6×3色=18>5+6+5=16)にしておきます。
今回パネルはデイジーチェインせず1枚なので`tile`は`1`、LEDセルの並びは通常のZ型配列、`doublebuffer`は`True`です。
``` code.py
matrix = rgbmatrix.RGBMatrix(
    width=64,
    height=32,
    bit_depth=6,
    rgb_pins=[  # Preserve GP4 & GP5 for standard STEMMA-QT
        board.GP2,  # R1
        board.GP3,  # G1
        board.GP6,  # B1
        board.GP7,  # R2
        board.GP8,  # G2
        board.GP9,  # B2
    ],
    addr_pins=[board.GP10, board.GP16, board.GP18, board.GP20],  # A  # B  # C  # D
    clock_pin=board.GP11,
    latch_pin=board.GP12,
    output_enable_pin=board.GP13,
    tile=1,
    serpentine=False,
    doublebuffer=True,
)
...
```
64x32のbmpファイルはPCからCircuitPythonのドライブ(H:とか)に適当にフォルダを作成して保存しておきます。今回は24ビットカラーのファイルを用意しましたが、いい感じに減色してくれるようで問題ありませんでした。
そして`TileGrid`を作成します。引数には先ほど読み込んだ`bitmap`とそこから作る`pixel_shader`、描画開始座標(今回はパネル左上)を渡します。32x32画像などを表示する場合はxを16にするとセンターに表示されます。

``` code.py
image_path = "/graphics/test.bmp"
try:
    bitmap = displayio.OnDiskBitmap(image_path)
except Exception as e:
    print(f"Error loading image: {e}")

tile_grid = displayio.TileGrid(
    bitmap, 
    pixel_shader=bitmap.pixel_shader,
    x=0,
    y=0
)            
...
```
続いて先ほどの`matrix`を元に`framebuffer`を作成し、空の`display_group`も作成してそれを描画対象に指定します。今回はグループは1つだけ、またグループと言っても今回は1枚のbitmapだけがグループ内の要素となります。
``` code.py
display = framebufferio.FramebufferDisplay(matrix)
display_group = displayio.Group()
display.root_group = display_group
...
```
そして`while`ループになります。念のためガベージコレクションして、先ほどの`Tile_grid`を`group`へ`append`すれば画像が表示されます[^steam]。ここが実際の表示開始部分になります。古い情報では`show()`が使われているものがありましたがこれは廃止され[^root]、この形式に変更されたようです[^why]。
あまり早くループを回すとメモリエラーが発生するようなので最後に適当にウェイトを入れておきます。
[^steam]:これを書いた後で見つけたのですが、ここまでの話というか公式ガイドの日本語訳が https://steam-tokyo.com/circuitpython-displayio-lib-1-5/ こちらにあります(ただし一部情報が古い)。
[^root]:https://github.com/adafruit/circuitpython/issues/7213
[^why]:なんでわかりにくくしたんだ、という気はする。
``` code.py
while True:
    try:
        gc.collect()
        if image_path:
            group.append(tile_grid)
            time.sleep(5)
...
```
複数の画像を切り替えるには続けて最初の`tile_grid`を`remove`して次に表示するファイルから作成した`Tile_Grid2`とかを再度`append`すればOKです。最初のファイルと同様に`image_path2`と`bitmap2`などとして設定しておいてください。
巷のスクロールやアニメーションのデモも同様の手法で画像を更新して実現しているようです。
``` code.py
# 上のtile_glidの実装の下に
tile_grid2 = displayio.TileGrid(
    bitmap2, 
    pixel_shader=bitmap2.pixel_shader,
    x=0,
    y=0
)

...

# 上の`time.sleep(5)の後に
            if image_path2:
                group.remove(tile_grid)
                gc.collect()
                group.append(tile_grid2)
                time.sleep(5)
                group.remove(tile_grid2)
...
```
基本的にはこれだけです。
Web上の情報にはスクロールとか文字表示とかアニメーションとかもっと複雑なものがいろいろありますが、まずは自分がお手軽なPythonでの画像表示の基本的な流れを理解するために試したものです。

最後に`code.py`の全体を載せておきます。
``` code.py
import gc
import board
import displayio
import rgbmatrix
import framebufferio
import time

displayio.release_displays()

# === Setup for Pico ===
# Setup rgbmatrix display (change pins to match your wiring)
matrix = rgbmatrix.RGBMatrix(
    width=64,
    height=32,
    bit_depth=6,
    rgb_pins=[  # Preserve GP4 & GP5 for standard STEMMA-QT
        board.GP2,  # R1
        board.GP3,  # G1
        board.GP6,  # B1
        board.GP7,  # R2
        board.GP8,  # G2
        board.GP9,  # B2
    ],
    addr_pins=[board.GP10, board.GP16, board.GP18, board.GP20],  # A  # B  # C  # D
    clock_pin=board.GP11,
    latch_pin=board.GP12,
    output_enable_pin=board.GP13,
    tile=1,
    serpentine=False,
    doublebuffer=True,
)

display = framebufferio.FramebufferDisplay(matrix)
group = displayio.Group()
display.root_group = group
# === end of pico setup === #

image_path = "/graphics/test.bmp"
image_path2 = "/graphics/test2.bmp"
try:
    bitmap = displayio.OnDiskBitmap(image_path)
    bitmap2 = displayio.OnDiskBitmap(image_path2)
except Exception as e:
    print(f"Error loading image: {e}")

tile_grid = displayio.TileGrid(
    bitmap, 
    pixel_shader=bitmap.pixel_shader,
    x=0,
    y=0
)
tile_grid2 = displayio.TileGrid(
    bitmap2, 
    pixel_shader=bitmap2.pixel_shader,
    x=0,
    y=0
)

# === Main Loop ===
while True:
    try:
        gc.collect()
        if image_path:
            group.append(tile_grid)
            time.sleep(5)
            if image_path2:
                group.remove(tile_grid)
                gc.collect()
                group.append(tile_grid2)
                time.sleep(5)
                group.remove(tile_grid2)
    except MemoryError:
        print("MemoryError! Trying to recover...")
        group = displayio.Group()
        display.root_group = group
        gc.collect()
        time.sleep(1)
```

今回は以上です。