WEBカメラ映像から物体の輪郭を3D空間に表示した
深度に輪郭を重ねると、カメラ映像が急に分かりやすくなります。Sobelで境界を抜き、60fpsを落とさない線の見せ方が要点。閾値とブラーの落とし所を探ります。
WEBカメラの映像から深度推定AIで物体の位置を計算して、3D空間に輪郭線を表示するシステムを作った。
midori273では地形のような表現だったけど、今回は物体の輪郭を強調する方向に変えた。Sobelフィルタでエッジ検出して、境界を白線で描画する。
なぜ作ったか
深度情報の表現方法を色々試したかった。
前回(midori273)の地形表現は面白かったけど、物体の境界が分かりにくかった。カメラに映ってる「物体がどこにあるか」を明確に示したい。
エッジ検出を入れたら、輪郭がハッキリして見やすくなるんじゃないかと思った。
システムの全体像
行こう。
処理は5つのステップで構成されてる。
1. WEBカメラ映像取得: リアルタイムで映像を取得
2. 深度推定: MiDaSモデル(ONNX形式)でGPU推論
3. 前処理: ガウシアンブラーでノイズ低減
4. エッジ検出: Sobelフィルタで境界を抽出
5. 3D表示: Three.jsで立体表示 + 輪郭線
WEBカメラの映像取得
まず、WEBカメラから映像を取得する処理を実装した。
async function setupWebcamDisplay() {
const videoElement = document.createElement('video');
videoElement.autoplay = true;
videoElement.playsInline = true;
// 利用可能なカメラデバイスを取得
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices = devices.filter(device => device.kind === 'videoinput');
// 最大解像度(1920×1080)で映像を取得
const constraints = {
video: {
deviceId: { exact: videoDevices[0].deviceId },
width: { ideal: 1920 },
height: { ideal: 1080 }
}
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
videoElement.srcObject = stream;
return videoElement;
}
ideal: 1920で指定してるのは、最大解像度を狙うため。実際に取得できる解像度はカメラに依存する。
最初は640×480で試したけど、深度推定の精度が低かった。1920×1080にしたら、細かい物体も検出できるようになった。
深度推定の実装
MiDaSモデル(ONNX形式)で深度推定を実行する。
class GPUInferenceManager {
constructor(modelPath) {
this.modelPath = modelPath;
this.session = null;
this.isWebGPUSupported = !!navigator.gpu;
this.inferenceConfig = {
width: 512,
height: 512
};
}
async loadModel() {
if (this.isWebGPUSupported) {
// WebGPUで実行(最速)
this.session = await ort.InferenceSession.create(
this.modelPath,
{ executionProviders: ["webgpu"] }
);
} else {
// フォールバック(WebGL → WASM)
this.session = await ort.InferenceSession.create(
this.modelPath,
{ executionProviders: ["webgl", "wasm"] }
);
}
}
async runInference(imageElement) {
const { width, height } = this.inferenceConfig;
// 画像を512×512にリサイズ
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
ctx.drawImage(imageElement, 0, 0, width, height);
// テンソルに変換
const tensor = this._prepareInputTensor(imageData.data, width, height);
// 推論実行
const results = await this.session.run({ "input": tensor });
const outputData = results["output"].data;
return { outputData, width, height };
}
}
WebGPUが使えるブラウザなら、WebGPUで実行。使えない場合はWebGLにフォールバック。それもダメならWASM。
推論は512×512で実行してる。これより大きいとリアルタイムで動かない。これより小さいと精度が落ちる。
最初は256×256で試した。速かったけど、細かい物体が検出できなかった。
次に768×768を試した。精度は上がったけど、fpsが20以下に落ちた。使い物にならない。
512×512なら、60fps近く出て、精度も実用的。ちょうどいいバランスだった。
エッジ検出の実装
Sobelフィルタで輪郭を検出する。
const detectEdges = (data, width, height) => {
const edges = new Float32Array(data.length);
// Sobelフィルタ(水平・垂直)
const sobelX = [
[-1, 0, 1],
[-2, 0, 2],
[-1, 0, 1]
];
const sobelY = [
[-1, -2, -1],
[0, 0, 0],
[1, 2, 1]
];
// 各ピクセルのグラディエントを計算
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
let gradX = 0;
let gradY = 0;
// 3×3の近傍を調査
for (let ky = -1; ky <= 1; ky++) {
for (let kx = -1; kx <= 1; kx++) {
const idx = (y + ky) * width + (x + kx);
gradX += data[idx] * sobelX[ky + 1][kx + 1];
gradY += data[idx] * sobelY[ky + 1][kx + 1];
}
}
// グラディエントの大きさ
const magnitude = Math.sqrt(gradX * gradX + gradY * gradY);
edges[y * width + x] = magnitude;
}
}
return edges;
};
Sobelフィルタは、X方向とY方向のグラディエントを計算して、その大きさを取る。これで、深度が急激に変化する箇所(物体の境界)を検出できる。
実際にエッジ検出を試してみてください:
最初はCannyエッジ検出を試した。精度は高かったけど、処理が重すぎた。fpsが30以下に落ちた。
Sobelフィルタに変えたら、60fps維持できた。精度も実用的。
これだ。
閾値の調整が重要:
ガウシアンブラーでノイズ低減
深度マップにはノイズが多い。ガウシアンブラーで平滑化する。
const applyGaussianBlur = (data, width, height) => {
const result = new Float32Array(data.length);
// 5×5ガウシアンカーネル
const kernel = [
[0.003, 0.013, 0.022, 0.013, 0.003],
[0.013, 0.059, 0.097, 0.059, 0.013],
[0.022, 0.097, 0.159, 0.097, 0.022],
[0.013, 0.059, 0.097, 0.059, 0.013],
[0.003, 0.013, 0.022, 0.013, 0.003]
];
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let sum = 0;
let weightSum = 0;
for (let ky = -2; ky <= 2; ky++) {
for (let kx = -2; kx <= 2; kx++) {
const posX = Math.min(Math.max(x + kx, 0), width - 1);
const posY = Math.min(Math.max(y + ky, 0), height - 1);
const idx = posY * width + posX;
const weight = kernel[ky + 2][kx + 2];
sum += data[idx] * weight;
weightSum += weight;
}
}
result[y * width + x] = sum / weightSum;
}
}
return result;
};
5×5のガウシアンカーネルを使ってる。
最初は3×3で試した。ノイズが残った。
7×7にしたら、滑らかになったけど、エッジがぼやけた。物体の境界が分かりにくくなった。
5×5が、ノイズ低減とエッジ保存のバランスが良かった。
カラーマッピング(物体の色分け)
k-meansクラスタリングで、深度を6段階に分けて、色を付ける。
const adaptiveClustering = (depthData, width, height, segmentCount) => {
// 初期クラスタ中心を均等に配置
const centroids = Array(segmentCount).fill(0)
.map((_, i) => i / (segmentCount - 1));
const assignments = new Uint8Array(depthData.length);
// k-meansクラスタリング(5回反復)
for (let iter = 0; iter < 5; iter++) {
// 各ピクセルを最も近いクラスタに割り当て
for (let i = 0; i < depthData.length; i++) {
let minDist = Infinity;
let bestCluster = 0;
for (let k = 0; k < segmentCount; k++) {
const dist = Math.abs(depthData[i] - centroids[k]);
if (dist < minDist) {
minDist = dist;
bestCluster = k;
}
}
assignments[i] = bestCluster;
}
// クラスタ中心を更新
// (詳細は省略)
}
return assignments;
};
反復回数は5回に設定した。
最初は10回にしてたけど、処理時間が長くなった。5回でも十分収束する。3回だと精度が落ちた。5回がちょうどいい。
セグメント数(クラスタ数)は6に設定。UIから変更できるようにしてある。2〜10まで調整可能。
6段階だと、物体が見分けやすい。10段階だと細かすぎて、逆に分かりにくかった。
k-meansの収束過程を確認:
3D表示
Three.jsで立体表示する。
// 立体表示用の高解像度ジオメトリ
const segmentsX = 128; // 横方向の分割数
const segmentsY = Math.floor(segmentsX / videoAspect);
const depthGeometry = new THREE.PlaneGeometry(
planeWidth,
planeHeight,
segmentsX,
segmentsY
);
// ShaderMaterialで凹凸を表現
const depthMaterial = new THREE.ShaderMaterial({
uniforms: {
depthMap: { value: depthTexture },
displacementScale: { value: 15.0 },
invertDepth: { value: true }
},
vertexShader: `
uniform sampler2D depthMap;
uniform float displacementScale;
uniform bool invertDepth;
void main() {
// 深度値を取得
vec4 depth = texture2D(depthMap, uv);
float depthValue = (depth.r + depth.g + depth.b) / 3.0;
if (invertDepth) {
depthValue = 1.0 - depthValue;
}
// 法線方向に頂点を移動
vec3 newPosition = position + normal * depthValue * displacementScale;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}
`,
fragmentShader: `
uniform sampler2D depthMap;
varying vec2 vUv;
void main() {
vec4 color = texture2D(depthMap, vUv);
gl_FragColor = color;
}
`,
side: THREE.DoubleSide
});
分割数は128×72(アスペクト比に合わせて)。
最初は64×36で試した。表面がカクカクだった。
256×144にしたら、滑らかになったけど、描画が重くなった。
128×72が、品質とパフォーマンスのバランスが良かった。
凹凸の強さ(displacementScale)は15.0に設定。UIで0〜50まで調整可能。
ハマったところ
エッジ検出の閾値
エッジ検出の閾値が難しかった。
const isEdge = edgeStrength > 0.1; // エッジ検出閾値
最初は0.05にしてた。ノイズも全部エッジとして検出されて、画面が真っ白になった。
0.2にしたら、重要な境界が検出されなかった。
0.1がちょうどいい。物体の境界だけが検出される。
カラーマッピングの透明度
カラーマッピングの透明度が難しかった。
不透明(1.0)だと、元の映像が見えない。
透明すぎる(0.1)だと、色分けが見えない。
0.5がちょうどいい。元の映像も見えるし、色分けも分かる。UIで調整できるようにしてある。
リアルタイム処理の同時アクセス
最初、深度推論が同時に複数回呼ばれて、エラーが出た。
let isDepthSessionBusy = false;
async function updateUnifiedDepthProcessing() {
// セッションがビジー状態なら待機
if (isDepthSessionBusy) {
requestAnimationFrame(updateUnifiedDepthProcessing);
return;
}
isDepthSessionBusy = true; // ビジー状態にセット
const result = await inferenceManager.runInference(videoElement);
// 処理...
isDepthSessionBusy = false; // ビジー状態を解除
requestAnimationFrame(updateUnifiedDepthProcessing);
}
ビジーフラグを追加。安定。
パフォーマンス
PCでの動作を測定した。
| 処理 | 時間 |
|------|------|
| 深度推論(WebGPU) | 約20ms |
| ガウシアンブラー | 約5ms |
| エッジ検出 | 約8ms |
| k-meansクラスタリング | 約10ms |
| 合計 | 約43ms |
43msなので、約23fps。60fpsには届かないけど、実用的な速度。
WebGLだと深度推論が約35msかかる。合計58ms(約17fps)。WebGPUの方が速い。
スマホだと、10fps以下になった。PCでの利用を推奨。
結果
WEBカメラ映像から物体の輪郭を抽出して、3D空間に表示できた。
- Sobelフィルタでエッジ検出
- ガウシアンブラーでノイズ低減
- k-meansクラスタリングで色分け
- ShaderMaterialで立体表示
エッジ検出の閾値とカラーマッピングの透明度の調整が難しかったけど、UIから変更できるようにした。
深度マップを体験できるデモ:
リアルタイム処理で23fps。もう少し最適化できそう。特に、k-meansの反復回数を減らすか、GPUで並列化すれば速くなる。
全体の処理フローをまとめると:
使ってみて
実際に体験してみてください:
デモを起動する(全画面表示)
ポイントは以下の3つ:
- 閾値0.1付近が見やすい(強すぎると境界が消え、弱すぎるとノイズが出る)
- ブラー5×5で滑らかさとエッジ保持のバランスを取る
- 23fps前後、GPU推論なら安定して表示可能
手応え。
まとめ
今回は、深度推定からエッジ検出、カラーマッピング、3D表示まで一連の処理を実装しました。
ポイントは以下の4つ:
- Sobelフィルタでエッジ検出(閾値0.1が最適)
- ガウシアンブラー(5×5カーネル)でノイズ低減
- k-meansクラスタリング(5回反復、6セグメント)で色分け
- ShaderMaterialで凹凸表示(分割数128×72、凹凸の強さ15.0)
エッジ検出の閾値と透明度の調整に時間がかかったけど、UIから変更できるようにして解決した。
同じような深度推定の可視化を試している方の参考になれば嬉しいです。
さらに深く学ぶには
この記事で興味を持った方におすすめのリンク:
自分の関連記事:
最後まで読んでくださり、ありがとうございました。