チャーハンノート

チャーハンの作り方に関する覚書

ポケットカメラ風 Webカメラの製作 (ラズパイ編)

ラズパイにポケットカメラ風映像を生成させた様子

前回の記事では、ゲームボーイポケットカメラ風の映像をPC画面上に再現する方法について検討した。
今回はラズパイ(Raspberry Pi)に同様の機能に実装し、Webカメラとして活用できるところまで挑戦する。

用意するもの

用意したのは以下のもの。

Raspberry Pi Zero 2 W
akizukidenshi.com

• ラズパイ用カメラモジュール
正規品のカメラは高額だったため、筆者はアマゾンで売られている互換カメラを使用した。 純正品のV1(OV5647)相当品とのこと。
https://amzn.asia/d/0eKI88Vj

Raspberry Pi Zero ケース
ラズパイゼロ向けのケースでは最も一般的なもの。カメラ用カバーも付属していてお得。
akizukidenshi.com

動作環境

使用したマイコン(Raspberry Pi Zero 2 W)の環境は以下のとおり。

Raspberry Pi OS Ver・・・6.6.16-v7
• プログラミング環境・・・Python Ver : 3.9.2

pythonモジュール
 • picamera2 Ver : 0.3.12
 • numpy Ver : 1.19.5
 • OpenCV Ver : 4.5.1

ラズパイ用のProcessingもあるが、カメラモジュールを扱う事例が少なく、筆者の知識では不可能だった。そこで、より実例が豊富なPythonOpenCVを用いてカメラモジュールを扱うことにした。
OpenCVそれ自体もラズパイにインストールするのはかなり難儀だったのだが、それに関するチュートリアルはいずれ別の機会に紹介したい。

なお、ラズパイには「USBガジェットモード」と呼ばれる機能がある。特殊なUSBケーブルでPCと接続し、VNCソフトを経由することで、PCからラズパイを遠隔操作することができる。
ここでいう特殊なUSBケーブルとは「OTG(On-he-Go)」規格に対応したケーブルのこと。 筆者の場合はラズパイのUSB-Micro端子と、PC(M1 MacBook Air)のUSB-C端子とを接続したかったので、下記リンクのケーブルを使用した。
https://amzn.asia/d/0cNn7gc5

このガジェットモードを使えば、ラズパイにLANケーブルやWiFi設定することなくインターネットも使用できる。そのため、参考サイトを見ながらラズパイの各種設定を行ったり、好きなエディタでコード記述したりと、いろんな作業が快適に行えるようになった。
何より、外出先などで場所を選ばずラズパイを使えるようになったことが一番のメリット。

処理フロー

前回記事のようにラズパイ上でProcessingとカメラモジュールを連携しようとしたが、なかなか思うように出来ず、Web上に情報が少ないため断念した。
ラズパイとカメラモジュールの活用事例で多いのはOpenCVだったため、あらためてOpenCV用のプログラムを作製することにした。イチからやり直しだが、画像処理フローのコンセプトは同じ。

• カメラ画像の読み込み( 128 x 112 px)
• カメラ画像を25階調のグレースケールに変換(輝度レベル : 0~24 )
• ディザリングアルゴリズム則って25階調 → 4階調へ変換する
• 変換した画像を拡大する(128 x 112 px → 640 x 560 px)
• 全画面表示 (解像度 1280 x 720)
これを繰り返すだけ。

PC(Mac)では余裕でポケットカメラ風の映像を表示することができた。しかし今回は圧倒的に性能の劣るラズパイで同様の処理をしようとしている。それが可能かどうかは全て、ディザリングのアルゴリズムにかかっている。

ディザリングアルゴリズムの再考

結論からいうと、前回の記事のようなディザリングアルゴリズムでは、まともに映像を表示することができなかった。画像を1枚更新するのに10秒以上かかり、紙芝居にも程遠い出来となった。
ここから更新速度の改善沼にハマっていくことになった。

ディザリング処理が遅くなった理由は、PCとラズパイの処理速度の違いはもちろん、アルゴリズムの効率の悪さもあった。
前回のアルゴリズムでは、128×112 px で25階調の画像データに対し、1ピクセルずつ4階調化の判定を行っていた。
これはつまり、画像データ1枚を処理するために14,336回もの計算(判断)を行っていることになる。これでは合理的な処理とは言えない。

色々と試行錯誤した結果、マスク方式によるディザリングを行うことにした。(マスク方式とは筆者が勝手に呼んでいるだけで、界隈でそう呼ばれているかは知らない)
例として16×16px・グレースケール5階調(輝度0〜4)で描かれた球体画像を用意し、白黒2階調にディザリングする様子を示す。

処理手順は以下の通り。

① 入力画像の輝度レベル0〜4に応じたレイヤーに分割する。→抽出画像(A0〜A4)とする。
② 輝度レベル0〜4に応じて黒(0)・白(1)比率を変えた5種類のマスクを用意する。→マスク(B0〜B4)とする。
③ A,Bの同じピクセル座標の値同士で掛け算する。→C0〜C4とする。(アダマール積というらしい)
④ C0〜C4の同じピクセル座標の値同士で足し算する。→これがディザリング後の出力画像となる。

出力された画像は、一見するとノイズだらけの画像に見えるかもしれない。しかし目を細めてみると、白黒2値でも球体の陰影のニュアンスが再現できているのではなかろうか(主観)。

前回のアルゴリズムではfor文で1ドットずつせっせと変換を行なっていたが、今回は行列同士で一気に演算するので、numpy側でパイプライン的な、いい感じの高速処理をしてくれるはず。
改善版のアルゴリズムができたところで、次章ではラズパイへの実装を試みる。

改善版プログラムの実装

前述の改善版アルゴリズムをラズパイ(Raspberry Pi)に実装していく。
作製したプログラムは以下のとおり。

import cv2
from picamera2 import Picamera2
import time
import numpy as np

# カラーパレットの定義(デフォルト・初代ゲームボーイ風)
gbtone_sw= np.array([(6, 55, 44), (32, 101, 96), (51, 166, 145), (95, 220, 204), # 初代ゲームボーイ風
                    (0,0,0),(80,80,80),(130,130,130),(250,250,250),              # グレースケール
                    (5,70,5),(30,145,80),(95,205,150),(185,250,230),             # ゲームボーイライト風
                    (250,250,250),(130,130,130),(80,80,80),(0,0,0)])             # 白黒反転グレースケール
gbtone_id=0

gbtone=gbtone_sw[gbtone_id*4 : (gbtone_id+1)*4]

# カメラ画像のサイズ
width = 128
height = 112

# リサイズ時の拡大率(Magnification rate)
mgf = 5                

# カメラの設定
picam2 = Picamera2()
picam2.configure(picam2.create_preview_configuration(main={"format": 'XRGB8888', "size": (width, height)}))
picam2.start()

# 画面の解像度
screen_resolution = (1280, 800)

# ウィンドウ名を指定
window_name = "Camera"

# ウィンドウ非表示の設定
cv2.namedWindow(window_name, cv2.WND_PROP_FULLSCREEN)
cv2.setWindowProperty(window_name, cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN)

# 背景画像の生成
im_bg = np.zeros((screen_resolution[1], screen_resolution[0], 3), dtype=np.uint8)
im_bg[:, :] = (25, 25, 25)


alpha = 2.1  # コントラストの初期値
beta = -30     # 輝度の初期値

alpha_df=alpha
beta_df=beta

# カメラ画像の中央座標
center_x = (screen_resolution[0] - width*mgf ) // 2
center_y = (screen_resolution[1] - height*mgf ) // 2- 40


#----------------------------------------------------------------------- 

# ディザリング判定マップの作成
pxtbls = [
    [   # 0
        [1, 1, 1, 1],[1, 1, 1, 1],[1, 1, 1, 1],[1, 1, 1, 1]
    ], [   # 1
        [1, 0, 0, 0],[0, 0, 0, 0],[0, 0, 1, 0],[0, 0, 0, 0]
    ], [   # 2
        [1, 0, 1, 0],[0, 0, 0, 0],[1, 0, 1, 0],[0, 0, 0, 0]
    ], [  # 3
        [1, 0, 1, 0],[0, 1, 0, 0],[1, 0, 1, 0],[0, 0, 0, 1]
    ], [   # 4
        [1, 0, 1, 0],[0, 1, 0, 1],[1, 0, 1, 0],[0, 1, 0, 1]
    ], [   # 5
        [1, 0, 1, 0],[0, 1, 1, 1],[1, 0, 1, 0],[1, 1, 0, 1]
    ], [   # 6
        [1, 0, 1, 0],[1, 1, 1, 1],[1, 0, 1, 0],[1, 1, 1, 1]
    ], [   # 7
        [1, 1, 1, 0],[1, 1, 1, 1],[1, 0, 1, 1],[1, 1, 1, 1]
    ], 
]

# ディザリング判定テーブルの拡張( 4x4 → 128x112 pixel)
pxtbls_exp = np.zeros((height, width, len(pxtbls)), dtype=int)
for idx, pxtbl in enumerate(pxtbls):
    new_table = []
    for i in range(height):
        row = pxtbl[i % len(pxtbl)]
        new_row = []
        for j in range(width):
            new_row.append(row[j % len(row)])
        new_table.append(new_row)
    pxtbls_exp[:, :, idx] = new_table

#-----------------------------------------------------------------------

# カメラ画像の取得・ディザリング・画面表示
while True:
    # 時間計測開始
    start_time = time.time()
    
    # 画像をキャプチャ
    im = picam2.capture_array()
    
    # 画像をグレースケールに変換
    im_gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY)
    # グレースケール画像をRGBに変換
    im_gray = cv2.cvtColor(im_gray, cv2.COLOR_GRAY2RGB)
    im_gray = cv2.convertScaleAbs(im_gray, alpha=alpha, beta=beta)
    
    # ノイズの追加
    noise = np.zeros_like(im_gray, np.uint8)
    cv2.randn(noise, mean=0, stddev=1.5)
    im_gray = cv2.add(im_gray, noise)
    im_gray2 = np.copy(im_gray)
    
    #輝度のレベル0〜24に応じてディザリングを行う
    for level in range(25) :
        # 定されたピクセルのみを置換するマスクを作成
        mask0 = (pxtbls_exp[:, :, int(level%8)] == 0)
        mask1 = (pxtbls_exp[:, :, int(level%8)] == 1)

        # ピクセルを指定された色に置換する
        im_gray2[np.logical_and(im_gray[:, :,0] >= int(level*10.3), im_gray[:, :,0] <= int((level+1)*10.3)-1) & mask0] = gbtone[(level+7)//8-1]
        im_gray2[np.logical_and(im_gray[:, :,0] >= int(level*10.3), im_gray[:, :,0] <= int((level+1)*10.3)-1) & mask1] = gbtone[(level+7)//8]
        

    # タテヨコとも(mgf)倍にリサイズ(補間なし)
    resized_image = cv2.resize(im_gray2, None, fx=mgf, fy=mgf, interpolation=cv2.INTER_NEAREST)
    

    # 画像を背景に描画
    im_bg[center_y:center_y+height*mgf, center_x:center_x+width*mgf] = resized_image

    # 画像をフルスクリーンで表示
    cv2.imshow(window_name, im_bg)
    
    # キー入力
    # キー入力を待つ
    key = cv2.waitKey(1)
    # キーイベントの処理
    if key == ord("q"):  # 強制終了
        break
    elif key == ord("w"):  # 輝度を上げる
        beta += 10
        print("beta = ",beta)
    elif key == ord("s"):  # 輝度を下げる
        beta -= 10
        print("beta = ",beta)
    elif key == ord("a"):  # コントラストを下げる
        alpha -= 0.1
        print("alpha = ",alpha)
    elif key == ord("d"):  # コントラストを上げる
        alpha += 0.1
        print("alpha = ",alpha)
    elif key == ord("r"): # 輝度・コントラストのreset
        alpha = alpha_df
        beta = beta_df
    elif key == ord("c"): # カラーパレットの切り替え
        gbtone_id +=1
        if gbtone_id >len(gbtone_sw)/4-1 :
            gbtone_id=0
        gbtone=gbtone_sw[gbtone_id*4 : (gbtone_id+1)*4]
        

    # 経過時間計算
    elapsed_time = time.time() - start_time
    
    # 次のカメラ画像取得まで待機
    time.sleep(max(0, 0.15 - elapsed_time))
    

# ウィンドウを閉じる
cv2.destroyAllWindows()

ラズパイをPC(Mac)に接続し、VNC経由でラズパイの画面を表示し、上記のプログラムを実行する。

実行結果はこんな感じ。

画像の更新スピードも実機かそれ以上になり、改善効果を確認できて一安心。

加えて、ポケットカメラ風映像が表示されているときに、キーボード操作で明るさ/コントラスト/カラーパレットを調整できるようにしてみた。

 • W/Sキー 明るさ調整
 • A/Dキー コントラスト調整
 • Rキー リセット
 • Cキー カラーパレット変更
 • Qキー 強制終了

さいごに

ひとまず、ラズパイでポケットカメラ風映像の再現は成功した。
あとはHDMIキャプチャーでラズパイのHDMI出力とPCのUSB端子を接続すれば、Webカメラとしての機能はほぼ完成する。
HDMIキャプチャーは、たとえば以下のようなものがある。
https://amzn.asia/d/09F6Gym6

そういうわけで、ポケットカメラWebカメラは完成までの目処を立てることができた。
今後も単体のガジェットとしての完成度を高めるための工夫を続けていきたい。

たとえば、今の筐体はラズパイそのものだが、より雰囲気を高めるためにゲームボーイ風の筐体に組み込んだりしてみたい。
そして、カメラ映像の明るさやコントラスト調整、そしてカラーパレット変更をWebカメラ実機側で操作できるようにもしたい。たとえばそれをゲームボーイ風筐体のボタン操作で行えれば理想的。
ただし、それを実現するにはプリント基板設計や発注のノウハウも必要になってくるので、まだまだ先の話になりそう。

基板作製も勉強しなくては・・・(KiCADはインストール済みだが、いっこうに製作がすすまない)

ということで、以上です。