Vision Proに13個目のカメラを増設してみた

# リアルタイムにビデオを取得したい
Vision Proの画面に映っている映像をAIでリアルタイムに解析しようとしています。
しかしiPhoneやiPadで簡単に取得できていたカメラからのビデオや写真をVision Proではプログラム的に取得することができなくなっており、ここで詰みそうになりました。Vision Proには12個もカメラが搭載されているのに、利用者がリアルタイムに何を見ているかはプライバシーに抵触するので利用が禁止されているのです。

しかしそれでは面白くないので、13個目のカメラ（Cam13）を自前で追加してみることにしました。

# Cam13の仕組み
外付けでカメラを追加して、その映像をWiFi経由でWKWebViewで受け取り、Vision Proの映像に重ねます。外付けカメラで取得した映像をAI処理することにより、Vision Proの映像を解析するような効果を上げようというアイデアです。
※今年のWWDC24でカメラ映像が解禁されたら、これを捨てて公式APIを利用します（笑）

# M5Stackのカメラユニットを繋げる
市販のウェブカメラでも良いのですがVision Proにマウントする自由度を上げたいので、安価で軽量でプログラムを焼くことができて、電源も取り回しやすいユニットを探しました。
選定したのはESP32S3搭載のM5Stackシリーズ UnitCAMS3。送料込みで2,500円ぐらいです。  

https://www.switch-science.com/products/9428

# カメラのリアルタイム映像をWiFiで送信
UnitCAMS3は出荷時にデモ用のスケッチがインストールされていますが、毎回手動でビデオ送信を開始する必要があります。やっぱり自動起動させたいし、あとあと小回りを効かせたいので、自前でスケッチをプログラムしておくことにします。
MacにVSCodeとPlatformIOをインストールして、下記のスケッチをビルド。

```cpp:main.cpp
// Access
//   http://192.168.4.1/stream
//   http://192.168.4.1/still

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <esp32cam.h>
#include <esp32cam-asyncweb.h>
#include "apis/camera/api_cam.h"

// WiFi
#define WIFI_SSID "--YourSSID--"
#define WIFI_PASSWORD "--password--"
bool isAccessPointMode = false;

// Camera
esp32cam::Resolution initialResolution;
constexpr esp32cam::Pins UnitCamS3{
  D0: 6,  D1: 15,  D2: 16,  D3: 7,
  D4: 5,  D5: 10,  D6:  4,  D7: 13,
  XCLK: 11,  PCLK: 12,  VSYNC: 42,
  HREF: 18,  SDA: 17,  SCL: 41,
  RESET: 21,  PWDN: -1,
};

// Web server
static void serveStill(AsyncWebServerRequest *request);
AsyncWebServer server(80);

void setup() {
  Serial.begin(115200);

  IPAddress ipAp(192, 168, 4, 1);
  IPAddress ip(192, 168, 1, 123);
  IPAddress gateway(192, 168, 1, 1);
  IPAddress subnet(255, 255, 255, 0);

  if(isAccessPointMode) {
    WiFi.mode(WIFI_AP);
    WiFi.softAP("VisionProCam13-WiFi");
    delay(100);
    WiFi.softAPConfig(ipAp, ip, subnet);
  }
  else {
    if (!WiFi.config(ip,gateway,subnet)){
        Serial.println("Failed to configure!");
    }
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
    while (WiFi.status() != WL_CONNECTED) {
      delay(500);
      Serial.print(".");
    }
    Serial.println("");
    Serial.println("WiFi connected");
    Serial.println("Access 'http://");
    Serial.print(WiFi.localIP());
    Serial.println("/stream' to connect webcam");
  }

  {
    using namespace esp32cam;

    initialResolution = Resolution::find(800, 600);
    Config cfg;
    cfg.setPins(UnitCamS3);
    cfg.setResolution(initialResolution);
    cfg.setJpeg(80);

    bool ok = Camera.begin(cfg);
    if (!ok) {
      Serial.println("camera initialize failure");
      delay(5000);
      ESP.restart();
    }
    Serial.println("camera initialize success");
  }

  server.on("/still", HTTP_GET, serveStill);
  server.on("/stream", HTTP_GET, streamJpg);
  server.begin();
}

void loop() {
  delay(1);
}

// Photo
static void serveStill(AsyncWebServerRequest *request) {
  auto frame = esp32cam::capture();
  if (frame == nullptr) {
    Serial.println("capture() failure");
    request->send(500, "text/plain", "still capture error\n");
    return;
  }
  AsyncWebServerResponse *response = request->beginResponse_P(200, "image/jpeg", frame->data(), frame->size());
  request->send(response);
}
```
PlatformIOでBuild→Runさせるとサーバーモードで待ち受けます。isAccessPointModeをtrueに設定すると、192.168.4.1で待ち受けるアクセスポイントモード、falseに設定すると192.168.1.123で待ち受けるステーションモードになります。
アクセスポイントモードだと30fps、ステーションモードだと10fpsほどです。Vision ProはWiFiでしかネットに繋がらないので、インターネットとカメラに同時にアクセスするためにはステーションモードで動作させる事になります。

# visionOSアプリで映像を受信する
visionOSアプリはSwiftUIで作りました。WKWebViewで"http://192.168.1.123/stream" からのストリーミングを受信するだけのウィンドウです。
Xcodeで新規プロジェクトを作成し、visionOSアプリを選択。Initial SceneはWindowを選択して進めて下さい。ContentView.swiftの中身は以下の通りです。
```swift:ContentView.swift
import SwiftUI
import RealityKit
import RealityKitContent
import WebKit

#if os(macOS)
struct WebView: NSViewRepresentable {
	let loardUrl: URL
	func makeNSView(context: Context) -> WKWebView {
		return WKWebView()
	}
	func updateNSView(_ uiView: WKWebView, context: Context) {
		let request = URLRequest(url: loardUrl)
		uiView.load(request)
	}
}
#else
struct WebView: UIViewRepresentable {
	let loardUrl: URL
	func makeUIView(context: Context) -> WKWebView {
		return WKWebView()
	}
	func updateUIView(_ uiView: WKWebView, context: Context) {
		let request = URLRequest(url: loardUrl)
		uiView.load(request)
	}
}
#endif

struct ContentView: View {
	var body: some View {
		WebView(loardUrl: URL(string: "http://192.168.1.123/stream")!)
	}
}
```
Info.plistに"Privacy - Local Network Usage Description"を追加して下さい。この設定が無いとLAN経由の通信ができずにWebViewが真っ白になります。
スチル映像を取得するには"http://192.168.1.123/still" にアクセスします。

# Vision Proの映像に重ねてみる
それではCam13映像をVision Proオリジナル映像に重ねてみます。
<iframe width="560" height="315" src="https://www.youtube.com/embed/HjN32xCPnnA?si=1uNere0X8XjqoYDm" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
映像の拡縮や位置合わせは手動です（汗）。このキャリブレーションの自動化が課題ですが、カメラをマウントして一度設定してしまえば大きなズレは起こらないのではと楽観視しています。

# 次のステップ
・Cam13用カメラマウントを3Dプリンタで作って固定する  
・Vision Proの映像とCam13の映像を重ねるキャリブレーションを自動化する  
・リアルタイムにAI画像解析をする。[これ](https://zenn.dev/alohayos/articles/d518c35e2576af)の追加機能  
・解析結果情報をVision Proの映像の上にHUD表示する  
・すべてをVision Proオンデバイスで行う（プライバシーを考慮）  

https://github.com/AlohaYos/VisionProCam13/tree/main