メインコンテンツへスキップ
見出し画像

脱・初心者! Python100行で自作ガジェット?! 無線防犯カメラから学ぶ技術

    筆者は驚いている、防犯カメラのガジェットを100行のPythonコードで実現できてしまうなんて。

    画像

    今回、想定しているESP32-S3の開発ボードは以下。筆者もAmazonで購入した。Pythonと表記していますが、ESP32(マイコン)向けのMicroPythonのコードになります。MicroPythonとPythonは異なる部分がありますが、PC向けのPythonにおいても活用できる技術だと思っています。

    ESP32を入門するならFeenoveのボードが品質が高いと思う🤩



    PCでカメラを動かすだけなら「10行くらいのコード」でできるが、今回は、手のひらにのるマイコンボードを使い、無線Wi-Fiで映像を飛ばし、実際に部屋の隅に設置して、スマホで閲覧できる「物理的なガジェット」が、Pythonコード100行で実現できるという話。


    必要なものは以下のボードのみ!2,000円台で買える。

    実践で学ぶ

    せっかくなので、この実装をケーススタディにして、「Python力」を一段上のレベルへ引き上げましょう。以下に掲載するのが、実際のソースコードです。行数は100行。 この短いコードの中には、HTTPサーバー・通信、HTML、MJPEGによるストリーミング、マルチスレッド処理といった、実務でも有益となる重要概念がぎっしり詰まっています。これらを1行ずつ紐解き、仕組みを完全に理解できたとき、「脱・初心者」を名乗っていいはずです。 しかも、画面の中だけで終わらない「実際に動く自作ガジェット」という最高の成果物付いてきます!(文章書くのに、Geminiに手伝ってもらったら、モリモリになってしまった。もうちょっと自然にしてほしいのだが…)

    import network
    import socket
    import time
    import _thread
    from camera import Camera, FrameSize, PixelFormat
    
    SSID = 'YOUR_SSID'
    PASSWORD = 'YOUR_PASSWORD'
    CAM_PIXEL_FORMAT = PixelFormat.JPEG 
    CURRENT_FRAME_SIZE = FrameSize.VGA  
    XCLK_FREQ = 10000000
    
    def connect_wifi(ssid, password):
        station = network.WLAN(network.STA_IF)
        station.active(True)
        station.connect(ssid, password)
        while not station.isconnected():
            time.sleep(1)
        return station.ifconfig()[0]
    
    def init_camera(frame_size, pixel_format, xclk_freq):
        cam = Camera(frame_size=frame_size, pixel_format=pixel_format, xclk_freq=xclk_freq, init=False)
        cam.init()
        return cam
    
    def generate_html():
        return """<!DOCTYPE html><html><head><meta charset="UTF-8"><title>ESP32 Camera</title></head>
    <body><div class="video-container"><h1>ESP32 Camera Live Stream</h1><img src="/stream" alt="Camera Stream">
    </div></body></html>
    """
    
    def handle_stream_request(client, cam):
        try:
            headers = (
                b'HTTP/1.1 200 OK\r\n'
                b'Content-Type: multipart/x-mixed-replace; boundary=frame\r\n'
                b'Cache-Control: no-cache\r\n\r\n')
            client.send(headers)
            while True:
                frame = cam.capture()
                if not frame:
                    time.sleep(0.01)
                    continue
                client.send(b'--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ' + str(len(frame)).encode() + b'\r\n\r\n')
                client.send(frame)
                client.send(b'\r\n')
        except OSError:
            pass
    
    def handle_html_request(client, html_content):
        response = 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n' + html_content
        client.send(response.encode())
    
    def handle_client(client, cam, html_content):
        try:
            client.settimeout(5.0)
            request = b""
            while True:
                data = client.recv(1024)
                if not data:
                    break
                request += data
                if request[-4:] == b"\r\n\r\n":
                    break
                if len(request) > 2048:
                    print("Request too large, closing connection.")
                    return
            req_text = request.decode('utf-8', 'ignore')
            if 'GET /stream' in req_text:
                client.settimeout(None)
                handle_stream_request(client, cam)
            elif 'GET /' in req_text:
                handle_html_request(client, html_content)
        except OSError:
            pass
        finally:
            try:
                client.close()
            except:
                pass
    
    def main():
        ip_address = connect_wifi(SSID, PASSWORD)
        print(f'Connected! IP: {ip_address}')
        cam = init_camera(CURRENT_FRAME_SIZE, CAM_PIXEL_FORMAT, XCLK_FREQ)
        html_content = generate_html()
        addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
        s = socket.socket()
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(addr)
        s.listen(2)
        
        while True:
            try:
                client, addr = s.accept()
                _thread.start_new_thread(handle_client, (client, cam, html_content))
            except Exception:
                time.sleep(1)
    
    main()


    実務でも使える重要な技術

    カメラハードウェアの初期化と設定(MicroPython限定)

    MicroPython特有の実装なのであまり重要じゃない。

    • プログラムの開始時に、cameraモジュールを用いてカメラセンサーを直接制御している。

    Wi-FiのAPへ接続(MicroPython限定)

    MicroPython特有の実装なのであまり重要じゃない。

    • networkモジュールを使用し、ESP32をステーション(STA)モードとして動作させる。

    • 指定したSSIDとパスワードを用いて、既存のルーター(アクセスポイント)へ接続を確立する。

    • 接続が完了するまでループで待機し、取得したIPアドレスを返すことで、サーバーがどこで稼働しているかを明確にする。

    HTTPサーバー

    低レベルなAPIのSocketを使ってWebサーバーを作っている。中級者以上でもやったことがない人がいるかも。ここでHTTPの概要を理解する。

    • socketモジュールを用いて、標準的なポート80番で待ち受ける簡易的な自前Webサーバーを構築している。

    • SO_REUSEADDRオプションを設定し、プログラム再起動時に「ポートが使用中」となるエラーを回避している。

    • メインループ(while True)で常にクライアントからの接続要求(リクエスト)を待ち続ける構成をとっている。

    HTMLファイルの配信

    💎特に重要な技術。Webサーバー、HTML、マイコン、PCがどのような関係になっているかを理解することが重要。

    • カメラ映像を表示させるための枠組みとなるHTMLコードを、プログラム内で文字列として生成し保持している。

    • クライアントがルートURL(GET /)にアクセスした際、標準的なHTTPステータス(200 OK)とtext/htmlのヘッダーを付与してHTMLデータを返却する。

    • HTML内には<img src="/stream">というタグが含まれており、ブラウザに次の映像リクエストを促す役割を持たせている。

    マルチスレッドによる並行処理

    💎重要な概念。低レベルなAPIを使っているので、中級者以上でもやったことがない人がいるかも。

    • ブラウザはHTMLを受信した直後に、画像ソースである/streamへ同時にアクセスを試みる。

    • ESP32が単一スレッドのままでは、映像を配信し続ける処理(無限ループ)に入ると、他の通信(HTMLの配信など)を一切受け付けなくなってしまう。

    • リクエストを受け付けるたびに_thread.start_new_threadで独立した処理(スレッド)を立ち上げることで、複数クライアントへの対応や、HTML配信と映像ストリーミングの同時実行を可能にしている。

    MJPEGによるストリーミングと持続的接続

    💎古い技術だが、まだ使われることが多いので、知っておくと役に立つこともある。HTTPプロトコルという概念の理解は超重要。

    • 映像リクエスト(GET /stream)に対しては、HTTPヘッダーのContent-Type: multipart/x-mixed-replaceを使用し、データが次々と更新されることをブラウザに伝達する。

    • 接続のタイムアウトを無効化(client.settimeout(None))し、サーバー側から通信を切断しないように設定している。

    • while Trueの無限ループ内で、カメラから取得した1枚のJPEG画像を区切り文字(今回は「frame」とした)とともに送り続けることで、パラパラ漫画の要領で動画を再生させる(Motion JPEG方式)。



    入門するのに最適なLEDや抵抗がセットもある。カメラで撮影中は赤いLEDを点灯させると映えるかも。



    コードの解説

    関数ごとに解説を書いた。

    グローバル変数の意味

    # 接続先Wi-FiアクセスポイントのSSIDを設定する。
    SSID = 'YOUR_SSID'
    
    # 接続先Wi-Fiのパスワードを設定する。
    PASSWORD = 'YOUR_PASSWORD'
    
    # カメラの出力フォーマットを設定する(Webストリーミングに適したJPEGを指定)。
    CAM_PIXEL_FORMAT = PixelFormat.JPEG 
    
    # カメラの解像度(フレームサイズ)を設定する。
    # 【解像度のサンプル設定】
    # FrameSize.QQVGA  : 160 x 120
    # FrameSize.QVGA   : 320 x 240
    # FrameSize.VGA    : 640 x 480 (現在の設定)
    # FrameSize.SVGA   : 800 x 600
    # FrameSize.UXGA   : 1600 x 1200
    CURRENT_FRAME_SIZE = FrameSize.VGA  
    
    # カメラモジュールを駆動する外部クロック周波数を設定する(安定動作のため10MHzを指定)。
    XCLK_FREQ = 10000000

    connect_wifi()

    • 指定されたSSIDとパスワードを使用してWi-Fiネットワークに接続する。

    init_camera()

    • 指定された解像度(フレームサイズ)、ピクセルフォーマット(JPEG)、およびクロック周波数でカメラハードウェアを初期化する。

    generate_html()

    • クライアント(ブラウザ)に送信するためのWebページのHTMLデータを文字列として返す。

    handle_stream_request()

    • 無限ループの中で、カメラから画像を取得し、クライアントに対して、JPEGデータ(Motion JPEGストリーム)としてクライアントへ送り続ける。

    def handle_stream_request(client, cam):
        # ストリーム配信(Motion JPEG)の送信処理を開始する。
        try:
            # 映像ストリーミング用(同一URLでデータを差し替え続ける設定)のHTTPレスポンスヘッダーを作成する。
            # 境界線(区切り文字)の名前を「frame」と定義し、ブラウザのキャッシュを無効化(no-cache)する。
            headers = (
                b'HTTP/1.1 200 OK\r\n'
                b'Content-Type: multipart/x-mixed-replace; boundary=frame\r\n'
                b'Cache-Control: no-cache\r\n\r\n')
            
            # 定義したHTTPヘッダーをクライアント(ブラウザ)へ送信する。
            client.send(headers)
            
            # 映像フレームを無限に配信し続けるためのループ処理を行う。
            while True:
                # カメラから最新の1フレーム(JPEGデータ)をキャプチャ(取得)する。
                frame = cam.capture()
                
                # 画像データが正常に取得できなかった場合の処理を行う。
                if not frame:
                    # 0.01秒待機し、CPUの負荷を下げつつ次のフレーム取得を試みる。
                    time.sleep(0.01)
                    continue
                    
                # 各画像フレームの区切り情報(ヘッダー)を送信する。
                # 区切り文字「--frame」、画像形式「image/jpeg」、および画像のデータサイズを伝える。
                client.send(b'--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ' + str(len(frame)).encode() + b'\r\n\r\n')
                
                # キャプチャしたJPEG画像データ本体をクライアントへ送信する。
                client.send(frame)
                
                # データの終了を示す改行コードを送信する。
                client.send(b'\r\n')
                
        # ブラウザが閉じられたことによる切断など、通信エラー(OSError)が発生した場合は処理を抜ける。
        except OSError:
            pass

    handle_html_request()

    • クライアントに対して、WebページのHTMLデータ(ブラウザの画面)を送信する。


    難しかったら、「MicroPythonプログラミング・ガイドブック」などから入るといいかも。今回の話は、複合的な技術を使っていて難易度がそれなりに高い。でも、わかったときの達成感とスキルは価値があると思う。


    handle_client()

    • クライアントからのHTTPリクエストを受信し、内容を解析する。

    • リクエストURLが GET /stream の場合は、handle_stream_requestを呼び出して映像ストリームを配信する。

    • リクエストURLが GET / (ルート)の場合は、handle_html_requestを呼び出してHTMLページを配信する。

    def handle_client(client, cam, html_content):
        # クライアントとの通信処理を開始する。
        try:
            # データの受信待ちタイムアウトを5秒に設定する。
            client.settimeout(5.0)
            # 受信データを格納するための空のバイト列を準備する。
            request = b""
            
            # リクエストデータを最後まで読み取るためのループ処理を行う。
            while True:
                # クライアントから最大1024バイトのデータを受信する。
                data = client.recv(1024)
                # データを受信できなかった場合(切断時など)はループを抜ける。
                if not data:
                    break
                # 受信したデータを変数に追加する。
                request += data
                # HTTPヘッダーの終端(\r\n\r\n)を検出したらループを抜ける。
                if request[-4:] == b"\r\n\r\n":
                    break
                # メモリ不足を防ぐため、データが2048バイトを超えた場合は通信を強制終了する。
                if len(request) > 2048:
                    print("Request too large, closing connection.")
                    return
                    
            # 受信したバイト列データをUTF-8の文字列に変換する(変換エラーは無視する)。
            req_text = request.decode('utf-8', 'ignore')
            
            # リクエスト内容が映像ストリーム(/stream)を要求しているか判定する。
            if 'GET /stream' in req_text:
                # 映像を継続的に送信するため、タイムアウトを無効化する。
                client.settimeout(None)
                # ストリーム配信用の関数を呼び出す。
                handle_stream_request(client, cam)
                
            # リクエスト内容がトップページ(/)を要求しているか判定する。
            elif 'GET /' in req_text:
                # HTMLページ配信用の関数を呼び出す。
                handle_html_request(client, html_content)
                
        # 通信中に発生したOSレベルのエラー(切断など)は無視する。
        except OSError:
            pass
            
        # エラーの有無にかかわらず、最後に必ず実行する。
        finally:
            try:
                # クライアントとの接続(ソケット)を閉じる。
                client.close()
            # 切断処理中に発生したエラーも無視する。
            except:
                pass

    main()

    • プログラム全体のメイン処理を実行する。

    • クライアントからの接続要求を受け付けるたびに新しいスレッド(_thread)を生成し、handle_clientによる並行処理を行う。

    def main():
        # Wi-Fiネットワークに接続し、ESP32のIPアドレスを取得する。
        ip_address = connect_wifi(SSID, PASSWORD)
        
        # 接続が完了したことをIPアドレスとともにコンソールに出力する。
        print(f'Connected! IP: {ip_address}')
        
        # 指定した解像度やフォーマットでカメラハードウェアを初期化する。
        cam = init_camera(CURRENT_FRAME_SIZE, CAM_PIXEL_FORMAT, XCLK_FREQ)
        
        # クライアントに配信するためのWebページのHTMLデータをあらかじめ生成する。
        html_content = generate_html()
        
        # 自身のすべてのアドレス(0.0.0.0)で、HTTP標準のポート80番を使用するためのアドレス情報を取得する。
        addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
        
        # ネットワーク通信を行うためのサーバー用ソケットを作成する。
        s = socket.socket()
        
        # プログラム再起動時などに「Address already in use」エラーを防ぐため、ソケットを即座に再利用可能にする設定を行う。
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        
        # 取得したアドレス情報(IPとポート)をソケットに紐付ける(バインドする)。
        s.bind(addr)
        
        # クライアントからの接続要求の待機(リッスン)を開始する(同時に最大2つまでの接続要求を保留できる)。
        s.listen(2)
        
        # サーバーとして常時稼働させるための無限ループ処理を行う。
        while True:
            try:
                # クライアントからの接続要求を受け入れ、通信用のクライアントソケットと相手のアドレスを取得する。
                client, addr = s.accept()
                
                # クライアントごとの処理を別スレッドで開始し、複数の接続を並行して処理できるようにする。
                _thread.start_new_thread(handle_client, (client, cam, html_content))
                
            # 接続待機中やスレッド作成時にエラーが発生した場合の処理を行う。
            except Exception:
                # エラーによる無限ループの暴走を防ぐため、1秒間待機する。
                time.sleep(1)


    準備については以下の記事で述べた。


    サーボモータなどを接続すれば、カメラの向きをサーボモーターで自由に制御できる。これが自作ガジェットの強み🤩🎈




    さらなる高みへ

    とりあえず動くものレベルを求めている場合、読まなくていいです。

    • MicroPythonにおいて _thread モジュールは非推奨のよう。asyncio系を活用した非同期処理に移行するのが良さそう。ただ、Pythonの非同期処理はバージョンによって書き方のが違うことがあり、少し扱いづらい側面がある。特にMirocoPythonだとバージョンや対応状況が複雑(MicroPythonは基本的なasyncioなら使えると思っていてよさそう)。

    • マルチスレッドなのに cam.capture() へのアクセスにロック(排他制御)がかかっていないため、複数クライントが来たらバグる可能性がある。複数のブラウザで開かない前提の話。

    • カメラのバッファのゼロコピー

    など

     
     
    TOEIC955。note月間4万ビュー/週に最も見られた記事に選出の経験。社会の役に立つロボットを作る。未来への影響も考える。プログラミング、電子工作、ガジェット自作などの試行錯誤を発信中。基本真面目だがユーモアを添えることも。Amazonアソシエイト参加者。収益は実験に活用。

    あなたへのおすすめ