屋外に置いて、後から争われても崩れない記録を残す装置を作っている。防犯そのものは約束しない。ドライブレコーダーと同じで、事故を防ぐとは言っていないのに役に立つ、あの立ち位置を狙っている。
その第一歩として、手元のESP32-CAMをWebカメラに仕立て、実際に何が撮れて、どこまで送れるのかを確かめることにした。解像度も上げたいし、フレームレートも欲しいし、無線で母艦へ流したい。この三つをどこまで両立できるか、という素朴な期待から入った。
先に結末を言ってしまうと、三つは両立しなかった。生画素は流せるものの、VGA1枚に数秒かかって実用にならない。しかも遅さの原因を剥がしていくと、犯人はネットワークではなくカメラの側にいた。カメラからデータを取り込む、まさにその動作がWiFiの転送を邪魔していた。この記事は、そこへたどり着くまでの測定の記録だ。速いWebカメラのレシピではない。なぜ速くならないのかを突き止めて、それを設計の判断に変えていった話になる。
届いた板が、注文したものと違った

安いESP32-CAMを一枚買った。届いて電源を入れ、センサの識別番号を読んだところで、いきなり最初の想定が崩れた。
[ID ] sensor PID=0x009B VER=0x00 MIDH=0x00 MIDL=0x00
PIDの0x009Bは、OV2640ではなかった。GC0308というセンサだ。データシートを当たると、最大解像度はVGA、つまり640×480どまり。おまけにハードウェアのJPEGエンコーダを積んでいない。
この二つは、あとで二重に効いてくる。解像度の低さは、すぐあとの画質の見積もりに響く。JPEGを持たないことは、記事の後半、無線がなぜ詰まるのかというところで響いてくる。商品ページには、センサ内蔵のJPEGエンコーダの有無なんて目立つ形では書かれていない。書かれていないのに、この一点が装置の性能をほぼ決めていた。
ピン定義も公開されていなかった。AI-Thinkerの標準品ならピン配置は既知だが、この板は別品種で、有志が公開しているリポジトリから拾って合わせた。ここは商品の当たり外れの話なので深追いしないが、クローン品を掴むとこの手間が乗る、という一例として書いておく。
JPEGを持たないので、映像は PIXFORMAT_RGB565 で取り出す。1画素あたり2バイトの、圧縮のかかっていない生の画素データだ。これをそのまま送るか、自前でソフトウェア圧縮してから送るか。この選択が、このあと延々と尾を引く。
まず、このカメラで何が見えるかを知りたい
送る話の前に、そもそも何が写るのかを押さえたい。屋外の記録装置なので、漠然ときれいに撮りたい、では設計にならない。どの距離の何を、どこまで判別できればいいのか。そこから解像度の要求が決まる。
次の節では、監視カメラの映像要件を定めた国際規格を物差しにして、手元のVGAで距離ごとに何が判別できるかを計算する。そこで一度、見たい範囲を丸ごとこのカメラで見る、という当初の望みが数式の上で成り立たないと分かる。その妥協を固めてから、無線で送る段に進む。詰まるのは、そのあとだ。
このカメラで何が見えるかを式で出す
判読の水準を規格から借りる
何がどこまで写ればいいかを、感覚ではなく数字で決めたい。物差しには、監視カメラの映像要件を定めた国際規格 IEC 62676-4 を借りる。この規格は、画素密度(被写体の実寸1mあたりに何画素を割り当てるかの指標。単位はpx/m)で判読の水準を段階づけている。
- 検出 25px/m。何かが居ると分かる
- 観察 62.5px/m。服装など特徴が分かる
- 識別 125px/m。知っている人物なら誰か分かる
- 特定 250px/m。知らない人物を法廷水準で特定できる
最初に一つ捨てる。特定の250は狙わない。ここを狙うとレンズと照明と露光が全部引きずられて、装置が別物になってしまう。
距離との対応は幾何で出る
画素密度は、カメラの水平画素数と画角と距離だけで決まる。水平画角をθ、距離をdとすると、距離dでの写野の幅は 2d·tan(θ/2) なので、
px/m = 水平画素数 / (2d · tan(θ/2))
になる。標準的なレンズを画角60°と置くと、分母は約1.155dまで簡単になる。手元のVGA、水平640pxを入れて、各水準が成立する距離を出すとこうなる。
| 判読水準 | 要求px/m | VGAで成立する距離 |
|---|---|---|
| 検出 | 25 | 22.2mまで |
| 観察 | 62.5 | 8.9mまで |
| 識別 | 125 | 4.4mまで |
| 特定 | 250 | 2.2mまで |
画角の60°は仮置きで、実測すると広角寄りの個体が多い。レンズが広いほど画素密度は下がるので、この表は楽観側の見積もりになる。
見たい範囲が、この時点で成立しない
このカメラを向けたい場所は、長辺が5mある。5mの位置での画素密度は110.8px/mで、観察の62.5は満たすが、識別の125にわずかに届かない。
惜しい、で流さずに、ここで範囲のほうを動かす。識別が成立するのは4.4mまでなので、5m全体を識別水準で見るのはあきらめて、4.4m以内に入る一角だけを識別の対象にする。妥協ではあるが、妥協の量が数字で出ている。どれだけ足りなかったかを後から検証できる形の妥協なら、設計として持ち越せる。
範囲を狭めても残る使い道を確認しておく。人が入ってきたと分かる検出の水準なら22mまで届くので、これは全体をカバーできる。誰なのかを見分けたければ4.4m以内。この二段の運用なら、手元のVGAでも回る。
ここまでが、撮る側の見積もりだった。次は送る側。1枚614,400バイトの生画素を、WiFiでどこまで流せるのか。ここから測定の話に入る。
生画素が詰まる。疑って、外していく
測定の道具立て
測りたいのは、生画素をどこまで流せるかと、詰まるならどこで詰まるかの二つ。そのために、使い捨ての測定ファームを一本書いた。実験はポートで切り替えられるようにしてある。
- ポート3334。素のTCP送出。カメラを経由せず、内部RAMの固定パターンを送るだけ
- ポート3333。生画素。RGB565のフレームをそのまま流す
- ポート80。JPEG。ソフトウェア変換したものをmultipartで流す
素のTCPの口を用意したのが、あとで効いた。カメラを通らない経路が一本あると、遅さがカメラ由来なのかネットワーク由来なのかを、いつでも切り分けられる。
受信側はLinux機で、二通りの受け方を使い分けた。目で見たいときはffplayで表示する。
nc 192.168.20.111 3333 | ffplay -f rawvideo -pixel_format rgb565be -video_size 640x480 -i -
生画素はヘッダも区切りもない裸のバイト列なので、解像度と画素形式は受け側に教えてやる必要がある。色が化けるときは rgb565be を rgb565le に替える。
スループットを測るときは、表示を挟まず、届いたバイトを数えて捨てるだけのPythonスクリプトで受けた。
sock = socket.create_connection((host, port), timeout=10)
total, t0 = 0, time.perf_counter()
while True:
chunk = sock.recv(65536)
if not chunk:
break
total += len(chunk)
elapsed = time.perf_counter() - t0
print(f"{total} bytes, {elapsed:.3f}s, {total*8/elapsed/1e6:.3f} Mbps")
表示側の描画の都合が測定に混ざらないようにするためだ。この受け方は、あとで一度、受信側の疑いを晴らすのにも役立った。
条件はシリアルから実行時に切り替える。解像度、フレームバッファの面数、XCLK(カメラへ供給するクロック)、タスクの優先度。書き込み直すと電波の状態や接続チャンネルが変わって比較が汚れるので、電源を落とさず条件だけ変えられるようにした。
症状。数枚流れて、止まる
生画素の口に受信側をつないで流すと、1枚か2枚は届く。そして止まる。VGAのRGB565は1枚614,400バイトで、届くときも1枚に数秒かかる。実効スループットは0.2から0.7Mbpsのあいだをさまよった。
比べるために素のTCPを測ると、話が奇妙になる。カメラを止めた状態なら7.19Mbps出る。ところがカメラを動かしたまま素のTCPを流すと、0.2から0.3Mbpsまで落ちる。送っているのは内部RAMの固定パターンで、カメラのデータには指一本触れていない。それなのに落ちる。
つまり、遅いのは生画素の送信だけではない。カメラが動いているだけで、無関係なTCP送信まで巻き添えで遅くなる。ここから犯人探しが始まった。
何を変えたら、どうなったか
先に、いじった条件と転送レートの対応を一枚にまとめておく。以下の各節は、この表を一行ずつ埋めていった過程だ。
| # | 経路 | カメラ | 変えたもの | 転送レート |
|---|---|---|---|---|
| 1 | 素のTCP | 停止 | なし(基準) | 7.19Mbps |
| 2 | 素のTCP | 稼働 | なし | 0.2〜0.3Mbps |
| 3 | 生画素 | 稼働 | 受信をffplay→読み捨てに変更 | 変化なし |
| 4 | 生画素 | 稼働 | setNoDelay(ナグル無効) | 変化なし |
| 5 | 生画素 | 稼働 | cam_task優先度 23→10 | 0.17→1.15Mbps |
| 6 | 生画素 | 稼働 | フレーム面数 2→1 | 改善なし |
| 7 | 生画素 | 稼働 | sendAllのバグ修正 | 切断が消滅(速度は不変) |
動いたのは行5だけで、それも1Mbps前後で頭打ちになる。行1と2のあいだの20倍以上の開きは、最後まで埋まらない。この二つが、この章の終わりに残る謎になる。
疑って、外す
最初に疑ったものは、ほぼ全部外れた。外れた順ではなく、同じ症状に出くわした人が疑いそうな順に並べる。それぞれ、なぜ外れと判定できたかを添えた。
上りの帯域不足。外れ
いちばん最初に疑う場所だが、表の行1のとおり、カメラを止めれば同じ経路で7.19Mbps出る。回線そのものは足りている。カメラのON/OFFでスループットが20倍以上動く以上、固定的な帯域不足では説明がつかない。
受信側の消費が追いつかない。外れ
表示に使っていたffplayが吸い切れず、TCPの受信窓が閉じているのではないか。前述の読み捨てスクリプトに替えても症状は変わらなかった(行3)。受信側は無罪。
送信バッファと遅延ACKの待ち合わせ。外れ
実測値が0.459Mbpsに張り付く場面があった。lwIP(ESP32のTCP/IP実装)の送信バッファ既定値5,744バイトを、遅延ACKの標準的なタイマ100msで割ると、ちょうど0.459Mbpsになる。数字が気持ちよく一致したので、これが犯人だと思い込みかけた。
だが、送信側のナグルアルゴリズム(小さな書き込みをまとめてから送る仕組み)を切っても変わらない(行4)。そもそもこの待ち合わせが律速なら、カメラのON/OFFに関係なく同じ値で頭打ちになるはずだが、カメラを止めると7Mbps出る。数字の一致は偶然で、実測値がたまたまその近くにいただけだった。きれいに割り切れる数字を見つけると人は信じたくなる、という教訓ごと残しておく。
タスクの優先度と競合。半分だけ当たり
カメラのドライバは、DMAが内部RAMに置いた画素をPSRAMのフレームバッファへ書き写すタスクを持っている。これがCPUを食ってTCPの処理を妨げているのではないか。まず、いま何のタスクがどのコアで動いているかを出す。
static void dumpTasks() {
UBaseType_t n = uxTaskGetNumberOfTasks();
TaskStatus_t* st = (TaskStatus_t*)malloc(n * sizeof(TaskStatus_t));
if (!st) return;
n = uxTaskGetSystemState(st, n, nullptr);
for (UBaseType_t i = 0; i < n; i++) {
Serial.printf("[TASK] %-16s prio=%2u core=%d\n",
st[i].pcTaskName,
(unsigned)st[i].uxCurrentPriority,
(int)xTaskGetAffinity(st[i].xHandle));
}
free(st);
}
出力の要点だけ抜くと、こうなっていた。
[TASK] cam_task prio=23 core=0
[TASK] wifi prio=23 core=0
[TASK] tiT prio=18 core=0
[TASK] loopTask prio= 1 core=1
カメラのコピータスク cam_task が優先度23でコア0にいて、lwIPの処理タスク tiT(優先度18)と同じコアに同居している。cam_taskが上位でコアを握ると、tiTは走れない。そこで、cam_taskの優先度を実行時に下げてみる。タスクを名前で探して、優先度を差し替えるだけでいい。
static void setCamPriority(UBaseType_t prio) {
UBaseType_t n = uxTaskGetNumberOfTasks();
TaskStatus_t* st = (TaskStatus_t*)malloc(n * sizeof(TaskStatus_t));
if (!st) return;
n = uxTaskGetSystemState(st, n, nullptr);
for (UBaseType_t i = 0; i < n; i++) {
if (strcmp(st[i].pcTaskName, "cam_task") == 0) {
vTaskPrioritySet(st[i].xHandle, prio); // tiT(18)より下の10へ
break;
}
}
free(st);
}
効いた。tiTの一つ下どころか、大きく下の10まで下げると、生画素の実効が0.17Mbpsから1.15Mbpsまで上がった(行5)。カメラのコピー処理がCPUを奪い、TCPの処理に回っていなかったわけだ。この競合は、確かに存在する律速だった。
ただし、当たったのは半分だけだった。優先度を下げても、フレーム面数を変えても(行6)、1Mbps前後から上へ抜けない。カメラを止めたときの7Mbpsには遠い。CPUの競合を緩める手が、どれも同じ天井に当たる。ということは、天井の正体はCPUだけではない。その正体は次の章で剥がす。
カメラのフレーム生成が遅い。外れ
1枚に数秒かかるのだから、そもそもカメラがフレームを作るのに数秒かかっているのではないか。ループの各区間をmicros()で挟んで、取得と送信を分けて測る。
while (client.connected() && millis() - t0 < kRawMs) {
uint32_t u0 = micros();
camera_fb_t* fb = esp_camera_fb_get();
capUs += micros() - u0; // 取得の時間
if (!fb) break;
u0 = micros();
bool ok = sendAll(client, fb->buf, fb->len);
sndUs += micros() - u0; // 送信の時間
esp_camera_fb_return(fb);
if (!ok) break;
frames++;
}
結果、フレーム取得は8マイクロ秒から140ミリ秒で返り、送信は1枚あたり2.5秒から数秒かかっていた。二重バッファなので、1枚を送っているあいだに次のフレームはとっくに出来上がっていて、取得は完成済みのバッファのポインタを受け取るだけになる。生成は速い。遅いのは送信だった。
なお、micros()は起動からの絶対時間を返すので、タスクがブロックして待たされた時間も差分に乗る。ここが乗らない計時だと、この切り分け自体が成り立たない。
自分のバグも一つ挟まっていた
「数枚流れて止まる」の直接の犯人は、切り分けの途中で見つかった。私の送信ルーチンのバグだ(行7)。
全バイトを送り切るためのループを、こう書いていた。
static bool sendAll(WiFiClient& client, const uint8_t* buf, size_t len) {
size_t sent = 0;
while (sent < len && client.connected()) {
size_t n = client.write(buf + sent, len - sent);
if (n == 0) return false; // ここが誤り
sent += n;
}
return sent == len;
}
client.write() が0を返すのは、切断ではない。送信バッファがいま一杯だという一時的な合図で、少し待てば空く。TCPは下で再送しながら粘っている。それをこのコードは、0を一度見ただけで諦めて接続を閉じていた。TCPが頑張っている最中に、上から叩き切っていたわけだ。
VGAの614,400バイトのような大物を押し込むと、途中で必ず一瞬の詰まりが来る。数枚は運よく詰まらず通り、どこかで0を引いて切れる。「数枚流れて止まる」という見え方と、ぴたりと一致する。
直し方は、0を切断と見なさないこと。接続が本当に生きているかは client.connected() で判定し、生きている限り待って再試行する。
static bool sendAll(WiFiClient& client, const uint8_t* buf, size_t len) {
size_t sent = 0;
while (sent < len) {
if (!client.connected()) return false; // 本物の切断だけで諦める
size_t n = client.write(buf + sent, len - sent);
if (n > 0) sent += n; // 送れたぶんだけ進む
else delay(1); // 一時的な詰まり。待って再試行
}
return true;
}
これで生画素は最後まで流れるようになった。遅いままだが、切れなくなった。ここから先の測定値は、すべてこの修正後のものだ。修正前の数値は、詰まる前に運よく通った分だけを測った汚れたデータなので、捨てた。
ここまでの整理
表に戻る。動いたのは行5だけで、それでも1Mbps前後の床を割れない。行1と2の開き、つまりカメラが動いているだけで、カメラを通らない素のTCPまで20倍以上遅くなる現象は、CPUの競合だけでは説明がつかないまま残っている。
CPUの先に、もう一枚、何かがある。次の章で、そこを測る。
二層あった転送低下の原因
コピーを止めても、送信レートは戻らない
前の章の終わりに残った謎は二つ。cam_taskの優先度を下げても送信レートが1Mbps前後から上へ伸びないこと。そして、カメラを通らない素のTCPの送信まで、カメラ稼働中は遅くなること。
CPUの競合が原因のすべてなら、CPUを空ければ空けるほど送信レートは伸びるはずだ。そこで、フレームバッファを単一にして、取得を呼ばずに送信だけを行う構成を作った。フレームバッファへの書き写しという、CPUを大きく食う仕事を減らすためだ。
ただし、この構成でもカメラは画素を吐き続けている。DMAの区切りごとの割り込みは入り続け、cam_taskも起こされ続ける。書き写しの本体が減っても、割り込み処理とタスク起床のオーバーヘッドは払い続けている。この構成で減らせるのは、コピーの本体だけだ。
このときの私は、コピーのCPU競合が犯人なのだから、これでカメラ全停止時の7Mbps付近まで戻ると踏んでいた。
素のTCPの送信レートは、実測で0.72Mbps。桁が一つ足りない。コピーの本体を止めてもなお、7Mbpsの一割しか出ない。この空振りが、二層目を探すきっかけになった。
クロックまで止めると、送信レートが上がる
次に、コピーどころかカメラそのものを止める。esp_camera_deinit() はドライバを畳み、XCLKの供給ごと止める。画素が来なくなるので、割り込みも起きない。この状態で1枚を送り、送り終わってから esp_camera_init() で復帰して次のフレームを撮る、という順で回した。
この状態での送信レートは3.5から5.0Mbps、平均4.5Mbpsまで上がった。
カメラの状態を段階的に止めていったときの送信レートを、一枚の表にまとめる。この表の数値は一連の切り分けを行ったセッションのもので、後半の再測定とは電波条件が異なる。表をまたぐ比較は、絶対値ではなく倍率で読んでほしい。送信レートは送信区間だけの値、実効レートはフレーム取得や初期化まで含めた1枚あたりの値だ。
| カメラの状態 | 送信レート | 実効レート |
|---|---|---|
| 完全に無関係(内部RAMのパターンを送出) | 7.19Mbps | 計測せず |
| deinitでクロックごと停止 | 3.5〜5.0Mbps(平均4.5) | 1.485Mbps |
| クロック稼働、コピー本体のみ停止(単一バッファ) | 0.72Mbps | 0.630Mbps |
| クロック稼働、コピーも稼働(二重バッファで連続取得) | 計測せず | 0.099Mbps |
実効レートの内訳を、行ごとに見ていく。deinit版は、送信そのものは1枚1.05秒まで縮んだが、毎フレームの esp_camera_init() に1.9秒かかるため、1枚あたりでは実効1.485Mbpsで頭打ちになる。単一バッファ版は、送信6.78秒に取得1.68秒が乗って実効0.630Mbps。二重バッファで連続取得しながら送る通常構成では、実効0.099Mbpsまで落ちる。
目を引くのは、表の2行目と3行目の段差だ。送信レートが4.5Mbpsと0.72Mbps。どちらの条件でもコピーの本体は止まっていて、違うのはクロックが回って割り込みが入り続けているかどうかだけ。そのクロックを止めた側で、送信レートが6倍になった。
転送低下の原因は二層になっている
ここまでの測定を重ねると、送信レートを下げていた原因が二層に分かれてくる。
一層目はCPUの競合。カメラのコピー処理が、lwIPの処理タスクからCPUを奪う。cam_taskの優先度を下げるとこの競合が緩んで、生画素の実効レートが0.17Mbpsから1.15Mbpsまで上がったのは、この層に手を打った効果だ。ただし効くのはそこまでで、送信レートは1Mbps前後から先へは伸びなかった。
二層目はカメラのクロックが動いていること自体。コピーの本体を止めてもクロックが回っている限り送信レートは0.72Mbpsで、クロックを止めると4.5Mbpsに上がる。優先度やバッファ構成をいじっても消えなかった残りの低下は、この層が作っていた。
一層目は設定で緩められる。二層目はカメラを止めない限り解けない。生画素の送信レートが1Mbps前後で頭打ちになっていたのは、この二層が重なった結果だ。
二層目の機序、自己阻害という読み
ここから先は仮説だと、先に断っておく。実測で確定しているのは二層目の存在まで、つまりクロックの稼働の有無で送信レートが6倍動く、という事実までだ。
まず、割り込みとタスク起床のオーバーヘッドだけでは説明がつかない。単一バッファの構成でも割り込みは入り続けているが、それがCPU時間の奪い合いなら、cam_taskの優先度を大きく下げた実験で解けているはずだ。実際には、優先度を下げても送信レートは1Mbps前後までしか戻らなかった。CPU側の対策がどれも同じあたりで頭打ちになる以上、残りの低下はCPUの外にあると考えるのが自然だろう。
そこで疑っているのが、無線への干渉だ。カメラの並列映像インターフェース(DVP。8本のデータ線とPCLK、HREF、VSYNCで画素を送る経路)が動いている状態そのもの。8本のデータ線が画素レートで一斉に切り替わり、この一斉スイッチングが雑音源になる。deinitはこれを丸ごと止める。
2.4GHzに効くのは基本波ではない。XCLKの20MHzもPCLKも、周波数だけを見れば2.4GHzのはるか下にある。効いてくるのは信号のエッジの速さが生む広帯域の成分で、立ち上がりが速いほど高い周波数まで裾を引く。8本がほぼランダムなパターンで切り替わるので、離散的な高調波というより帯域全体の雑音の底上げになる。その底が2.4GHz帯まで届いているのだろう。
この見立てには、支えになる観測が一つある。deinit版の測定中、RSSI(受信信号強度)は-40から-59dBmまで振れたが、RSSIの振れと送信レートは連動していなかった。-44dBmで3.5Mbps、-59dBmで5.0Mbpsと、むしろ逆にすら見える。信号強度が送信レートを決めているなら連動するはずで、連動しないなら、送信レートを決めているのは信号の強さではない。
通信の品質を決めるのは、信号強度そのものではなく、信号と雑音の差だ。自機の回路が撒く雑音で自機の受信の底が持ち上がれば、RSSIが同じでも差は縮む。WiFiは差が縮むと変調を落として物理速度を下げる。つまり、カメラの雑音で自分のWiFiの受信が鈍る。自分の出したノイズで自分の通信を邪魔する、いわば自己阻害が起きているのだと考えている。
機序をこれ以上深く突き止める作業、たとえば割り込みの実測やスペクトラムの観測は、この記事の範囲から外す。この仮説で十分だと考えるのは、次の章の対策がなぜ効くのかを、この見立てで説明できるからだ。機序を完全に突き止めるのは、装置を組み替えるときに改めてやればいい。
まだ切り分けきれていない点
実は、deinitの実験には交絡がある。クロックを止めると、割り込みとタスク起床も同時に消える。送信レートの0.72から4.5への変化のうち、どこまでが無線干渉の解消で、どこまでが割り込み停止の効果かは、この実験だけでは分けられない。
外科的に分けるなら、カメラを一切通らない素のTCP送出で、カメラ停止、cam_task優先度23、優先度10の三本を取ればいい。優先度を緩めても素のTCPの送信レートが戻らないなら、残りは無線側だと一軸で確定できる。あわせてXCLKを20、10、5MHzと振って、素のTCPの送信レートが段階的に戻るかを見れば、干渉がクロックレートに連動する裏取りになる。この確認は、まだやれていない。やったら結果を追記する。
だから、いま言い切れるのはここまでだ。CPUの競合だけでは説明のつかない送信レートの低下が実測で存在し、カメラのクロック停止でのみ解ける。機序として最も観測と整合するのは無線の自己阻害だが、確定はしていない。
対策を設計に反映する
層ごとに、打つ手を変える
原因を二層に分けたのは、層ごとに違う手を打つためだ。ここまでの測定から、打ち手はこう整理できる。
一層目のCPU競合には三つ。cam_taskの優先度をtiTより下げる。送信ルーチンの誤切断を直す。送るデータをJPEG圧縮で小さくして、送信処理の総量を減らす。
二層目のクロック由来の低下には、一つしかない。撮る時間と送る時間を分けることだ。送るあいだカメラを止めれば、送信は素の無線の速さで走れる。止め方には二つの粒度を用意した。1枚ごとに止める毎フレームdeinit方式と、PSRAMへN枚撮り貯めてからまとめて止めて送る一括方式だ。
これらを全部積んで、測り直した。
生画素のまま、どこまで速くなったか
以下の数値はすべて同一セッション、AP近接で取った。本文前半の数値とは電波条件が異なるため、突き合わせは絶対値ではなく倍率で読んでほしい。基準となるカメラ全停止の素のTCPは、このセッションで9.563Mbpsだった。
| 構成 | 実効レート | fps換算 |
|---|---|---|
| リアルタイム送信、cam_task優先度23 | 0.520Mbps | 0.11 |
| リアルタイム送信、優先度10 | 0.890Mbps | 0.18 |
| 毎フレームdeinit | 2.658Mbps | 0.54 |
| 一括(2枚撮り貯めてから送信) | 4.108Mbps | 0.84 |
fps換算は、実効レートをVGA生画素1枚のビット数(614,400×8)で割った値だ。
何も対策しない0.11fpsから、一括方式の0.84fpsまで、8倍になった。HWエンコーダもソフト圧縮もない、生画素のままでこれだ。
一括方式の送信フェーズだけを取り出すと8.292Mbpsで、基準9.563Mbpsの87%まで戻っている。カメラを止めて送れば、無線はほぼ素の力で働く。二層目の存在を、逆側から裏づける数字でもある。
伸びしろも見積もれる。一括の1枚あたりの内訳は、撮影0.39秒、送信0.59秒、initの償却0.14秒だった。撮り貯め枚数を増やせばinitの償却はさらに薄まるが、漸近は撮影と送信の和で決まって、約1.0fps。0.84はその8割強まで来ているので、この板の生画素はここが実力とみていい。
JPEGでは、圧縮が新しい支配項として露出した
同じ三方式を、JPEG(品質80、VGA)でも測った。1枚あたりの内訳だ。
| 構成 | 撮影 | 圧縮 | 送信 | 実効レート | fps換算 |
|---|---|---|---|---|---|
| リアルタイム送信 | 694ms | 534ms | 624ms | 0.140Mbps | 0.54 |
| 毎フレームdeinit | 698ms | 522ms | 37ms | 0.172Mbps | 0.65 |
| 一括(3枚撮り貯め) | 142ms | 485ms | 31ms | 0.366Mbps | 1.32 |
fps換算は、実効レートを各測定のJPEG1枚の平均ビット数(32,514から34,665バイト×8)で割った値だ。なお、以前サンプルファームで確認した約2fpsはQVGA品質10での値で、この表はVGA品質80に統一してあるため、直接は比べられない。
この表からは、三つのことが読み取れる。
送信の列を縦に見ると、624msが37msへ、17分の1になる。同じ35KB前後のJPEGを送るのに、カメラ稼働中は0.6秒、止めてからなら0.04秒。二層目の低下は、614KBの生画素だけでなく35KBの小物にも同じ倍率で効いていた。詰まりの原因はデータの大きさではなく、カメラが動いていることそのものだ、という前の章の主張に、裏づけがもう一つ増える。
圧縮の列は、三方式とも0.5秒前後でびくともしない。送り方をどう工夫しても frame2jpg のコストは変わらない。無線側の詰まりを全部退治した一括構成では、圧縮が1枚の時間の76%を占めて、新しい支配項として立った。ソフトウェアJPEGはVGAで2fpsが天井だと、実測から言える。
撮影の列で一括だけ142msと短いのは、連続で撮るぶん、再init後の同期待ちを1回しか払わないためだ。initコストの償却が、撮影の列に現れている。
もう一つ、生画素とは逆の現象がある。JPEGでは、毎フレームdeinit(0.65fps)と一括(1.32fps)の差が、止め方の差ではなくinit償却の差で開く。送信が軽いので、止めること自体の利得が小さい。JPEGにとっての敵は無線ではなく、圧縮の0.5秒だ。
板を替える判断も、同じ測定から出る
先に、このカメラは識別水準(125px/m)を4.4mまでしか届かせられない、と計算した。見たい範囲の5mに識別を届かせるには、式を逆に解くと水平722px以上が要る。VGAの640では足りず、1600px級のセンサが要る。
そして今回の測定が、二つ目の要件を実測で確定させた。ハードウェアJPEGエンコーダだ。ソフトウェア圧縮の0.5秒は、送り方の工夫では消えない。センサがJPEGを直接吐けば、この0.5秒がまるごと消え、ついでにRGB565のコピーもCPUから消える。冒頭で、商品ページに書かれていないのにこの装置の性能をほぼ決めていた、と書いた一点が、ここで次の板の必須要件に昇格する。
解像度1600px以上、ハードウェアJPEG搭載。この二つを満たすセンサを持つ板へ乗り換える。PSRAMへDMAが直接届く世代のSoCを選べば、コピーの構造そのものも変わる。乗り換え先での再測定は、稿を改める。
まとめ
HWエンコーダ抜きのカメラでWebカメラを作ろうとしたら、カメラからのデータ取得がWiFiの転送を阻害していた。原因は二層あった。カメラのコピー処理がCPUを奪う競合と、カメラのクロックが動いていること自体が自分のWiFi受信を鈍らせる自己阻害だ。
各層に手を打って測り直した結果、生画素のままで0.11fpsが0.84fpsになった。8倍だ。撮る時間と送る時間を分ければ、送信は基準の87%まで戻る。そしてJPEGでは、無線の詰まりを退治した先で、ソフトウェア圧縮の0.5秒が新しい支配項として顔を出した。それが次の板の要件になる。
届いた板は注文と違ったし、期待した性能も出なかった。だが、出ない理由を二層まで測り切ったことで、どう組めば何fps出るのか、次に何を買うべきかが、感覚ではなく数字で言えるようになった。この板の元は取れたと思っている。
続編の予告
三つ、宿題が残っている。
一つは、二層目の機序の裏取りだ。XCLKを振って送信レートとの連動を見る実験は、クロックを落とすとデバイスとの通信自体が成立しなくなり、断念した。優先度二水準の比較は取れているが、自己阻害を確定させる決定打はまだない。スペクトラムを覗ける環境を作ってから出直す。
一つは、乗り換え先での再測定だ。ハードウェアJPEGとPSRAM直結DMAを持つ板で、同じ測定治具を回し、二層がどう変わるかを見る。
そしてもう一つ。毎回deinitでカメラを止めるのは、正直、力技で負けた感がある。本来やりたいのは、ドライバを畳まずにDMAとセンサだけを待たせ、ミリ秒単位で撮影と送信を切り替える構造だ。ESP-IDFベースで取り込み層から作り直す案も含めて、次の板で検討する。
どれも、結果が出たら続編として書く。