はじめに
こんにちは。
先日、大学のサークル仲間と久しぶりに飲み会がありまして、「最近コード書いてないなー」なんて話をしていたんです。そこで話題になったのが、「今はAI(Geminiなど)に頼めば、大抵のプログラムは書けちゃうよね」 ということ。
そこで、お酒を飲みながらすごく冷静な頭で考えたのが、くだらない願望を形にするこのプログラムです。
「PCに向かって中指を立てたら、それを検知してPCをシャットダウンさせたい」
イライラした時、物理的にPCを壊す前にシステムを落とす。そんなアンガーマネジメント(?)システムです。
出典:2025年 M-1グランプリ 決勝1本目 ヤーレンズ(Geminiにより生成)
今回はその第一歩として、 「中指を立てた状態(Fk Sign)を正確に検出する」までの、意外と険しかった道のりを紹介します。
※本記事は技術的な知見の共有を目的としており、特定の他者を攻撃する意図はありません
試行錯誤1:YOLOでの画像認識(失敗)
最初に考えたのは、王道の物体検出モデル「YOLO (You Only Look Once)」を使う方法です。
- Webカメラで中指を立てている動画を撮影
- フレームごとに画像を切り出し
- ラベリングしてYOLOに学習させる
という手順を踏みました。
結果:失敗 ❌️
全然うまくいきませんでした。
冷静に考えると、「中指を立てた手」と「ただの拳(グー)」は、画像における画素の占有領域(バウンディングボックス)が9割以上同じなんです。
💡 YOLOはテクスチャ(見た目)や形状の輪郭を学習しますが、今回の課題には不向きでした。
逆に、後に採用することになるMTシステム×MediaPipeの手法は『骨格の構造的関係性』を見るため、照明条件や手の色、背景に依存しにくいという利点があります。この時点ではそこに気づけず、画素情報だけで戦おうとしたのが敗因でした。
指一本の有無だけで判断させるには、YOLOのような物体検出モデルでは大量のデータセットと厳密なチューニングが必要で、個人の趣味開発には不向きでした。
解決策:MediaPipe × MTシステム(異常検知)
YOLOのような「画像認識」ではなく、GoogleのMediaPipeで手の形状(ランドマーク)を取得し、その数値データを解析する方針に切り替えました。
そこで採用したのが、MTシステム(マハラノビス・タグチ・メソッド) です。
MTシステムとは?
主に製造業の異常検知などで使われる手法です。
データの「相関」を考慮したマハラノビス距離を計算することで、**「正常なデータ群の中心から、どれくらい離れているか(どれくらい異常か)」**を数値化できます。
今回のアイデア
- 単位空間(基準データ): 手の甲をカメラに向け、中指をのみ立てた状態
- 信号データ(入力): 今カメラに映っている手
- 判定: マハラノビス距離がある閾値を超えたら「異常(=普通の手の状態や、様々な手の形)」とみなす
実装のポイント
-
2Dではなく「3D座標」を使う
MediaPipeは手のランドマークの x, y (2D) だけでなく z (深度) も取得できます。
今回はこの3D空間での関節角度などのパラメータを使用しました。これは2D画像だけで判定しようとしたYOLOの反省から来ています。 -
特徴量の設計(ここが重要!)
手首の座標や位置情報(x, y)をそのまま使うと、「画面のどこに手があるか」に依存してしまいます。
そこで、位置不変とするために、座標データから**「関節の角度」など12個のパラメータ**を計算して抽出しました。
▼ 使用した12個のパラメータ
[Thumb_A, Index_A, Middle_A, Ring_A, Pinky_A, Thumb_R, Index_R, Middle_R, Ring_R, Pinky_R, Wrist_Z, IndexTip_Z] -
マハラノビス距離の計算
抽出した12次元のデータを元に、基準となる空間(単位空間)を作成し、入力された手の形がそこからどれくらい離れているか(距離 $D^2$)を計算します。
ここで少し専門的な話になりますが、MT法におけるマハラノビス距離の計算式は以下の通りです。
$$D^2 = \frac{1}{k} Z^T R^{-1} Z$$
(ここで $k$ は項目数、$Z$ は基準化された信号データベクトル、$R^{-1}$ は単位空間の相関行列の逆行列を表します)
JavaScriptで行列計算を行う場合、math.js などのライブラリを使うか、自前で計算ロジックを実装します。数式自体はシンプルなので、計算コストは非常に低いです。
実装コード※このコードは検出器であり、ブラウザのセキュリティ制約上、これ単体ではPCのシャットダウンは行われません
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MT法ジェスチャー検出 (Middle Finger)</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
/* 鏡像反転 */
#live-video, #output-canvas { transform: scaleX(-1); }
/* オーバーレイスタイル */
.overlay-status {
position: absolute; top: 10px; left: 10px;
background: rgba(0,0,0,0.7); color: white;
padding: 5px 10px; border-radius: 5px; font-size: 14px;
z-index: 50; display: none;
}
/* 検出時のメッセージ */
#gesture-alert {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(220, 38, 38, 0.9); /* Red */
color: white;
padding: 20px 40px;
border-radius: 15px;
font-size: 2rem;
font-weight: bold;
display: none;
z-index: 60;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
}
/* MD値のメーター */
.md-meter {
position: absolute;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: #00ff00;
padding: 10px 15px;
border-radius: 8px;
font-family: monospace;
z-index: 50;
text-align: right;
}
</style>
<!-- TensorFlow.js & MediaPipe -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/hand-pose-detection"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>
</head>
<body class="flex flex-col items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl shadow-2xl overflow-hidden w-full max-w-4xl">
<div class="p-4 border-b border-gray-200 flex justify-between items-center">
<h1 class="text-xl font-bold text-gray-800">🖕 MT法ジェスチャー検出</h1>
<div id="fps-display" class="bg-gray-100 text-gray-600 px-2 py-1 rounded text-sm font-mono">FPS: 0</div>
</div>
<div class="relative bg-black w-full" style="padding-top: 56.25%;">
<video id="live-video" class="absolute top-0 left-0 w-full h-full object-cover" playsinline muted autoplay></video>
<canvas id="output-canvas" class="absolute top-0 left-0 w-full h-full z-10"></canvas>
<!-- 検出アラート -->
<div id="gesture-alert">🖕 Middle Finger検出!</div>
<!-- MD値表示 -->
<div class="md-meter">
<div class="text-xs text-gray-400">Mahalanobis Distance</div>
<div class="text-2xl font-bold" id="md-value-display">--</div>
<div class="text-xs text-gray-400 mt-1">閾値: <span id="threshold-display">50.0</span></div>
</div>
<!-- 録画ステータス -->
<div id="recording-status" class="overlay-status border-l-4 border-red-500">
🔴 REC <span id="rec-timer">0.00s</span>
</div>
<div id="loading-message" class="absolute inset-0 flex items-center justify-center bg-white/90 z-20 transition-opacity duration-500">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p class="text-blue-600 font-semibold">AIモデルを準備中...</p>
</div>
</div>
</div>
<!-- コントロールパネル -->
<div class="p-6 bg-gray-50 flex flex-col gap-4">
<!-- 閾値設定エリア -->
<div class="flex items-center gap-4 bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<div class="flex flex-col">
<label for="threshold-input" class="text-sm font-bold text-gray-700">🎯 MD閾値設定</label>
<span class="text-xs text-gray-500">この値以下で検出と判定</span>
</div>
<!-- ✅ デフォルト値を50.0に変更 -->
<input type="number" id="threshold-input" value="50.0" step="0.1" min="0"
class="border-2 border-blue-200 rounded-lg px-3 py-2 w-24 text-center font-bold text-lg focus:outline-none focus:border-blue-500 transition-colors">
<div class="flex-1">
<!-- ✅ スライダーのmaxを300、デフォルトを50.0に変更 -->
<input type="range" id="threshold-slider" min="0.1" max="300" step="0.1" value="50.0" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer">
</div>
</div>
<!-- 記録コントロール(既存機能) -->
<div class="flex flex-col md:flex-row gap-6 justify-between items-center border-t border-gray-200 pt-4">
<div class="flex flex-col gap-2 w-full md:w-auto">
<label class="text-sm font-semibold text-gray-700">⏱ 記録時間 (秒)</label>
<div class="flex items-center gap-2">
<input type="number" id="duration-input" value="5" min="1" max="60" class="border border-gray-300 rounded px-3 py-2 w-24 text-center focus:outline-none focus:ring-2 focus:ring-blue-500">
<span class="text-sm text-gray-500">秒間 (Spaceキーで開始)</span>
</div>
</div>
<div class="flex gap-3 w-full md:w-auto justify-end">
<button id="btn-record" class="flex-1 md:flex-none bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform transition active:scale-95 flex items-center justify-center gap-2 min-w-[160px]">
<span>⏺ 記録開始</span>
</button>
<button id="btn-download" disabled class="hidden flex-1 md:flex-none bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-6 rounded-lg shadow-lg transform transition active:scale-95 flex items-center justify-center gap-2 min-w-[160px]">
<span>📥 CSV保存</span>
</button>
</div>
</div>
</div>
</div>
<script type="module">
// ==========================================
// 📊 MTシステム パラメータ (JSONより)
// ==========================================
// 12次元: [Thumb_A, Index_A, Middle_A, Ring_A, Pinky_A, Thumb_R, Index_R, Middle_R, Ring_R, Pinky_R, Wrist_Z, IndexTip_Z]
const MEAN_VECTOR = [
132.3313908205841, 95.27830319888736, 133.33923504867857, 101.39695410292079, 112.77420027816422,
0.8987788595271211, 0.765036161335187, 0.9183741307371353, 0.7981223922114042, 0.8459568845618916,
0.03348315855354657, 0.050300218358831715
];
const STD_VECTOR = [
4.856308918631946, 7.4347144932758935, 5.196586378103027, 3.2948501202635216, 3.5242816202775793,
0.028855976321935237, 0.04141397001142844, 0.018008511309906803, 0.017631336272550823, 0.016834284484494554,
0.012877993272474707, 0.004472992661529205
];
const INV_CORR_MATRIX = [
[5.5563067115843445, -16.016919599440534, 5.859385849871325, -0.38869601274930154, 1.1291666894530858, -3.6870468844445545, 21.031924287810675, -7.175602268949982, 1.9347497361482957, -3.0878053666992002, 6.427759272531361, -4.853063131922006],
[-16.016919599440563, 263.82463334827645, 21.042024704437203, -24.692060903574458, 9.27109413292704, 7.162351530663404, -274.9348470501335, -12.332687485524051, 34.922342675050324, -13.763167108305701, -8.686706347944856, 28.71398809400597],
[5.859385849871206, 21.04202470443745, 193.71654594590208, -10.523950096444716, 6.556989060118147, -9.625990756501338, -10.753731521236404, -190.86700516032792, 18.2931899634625, -12.304818070519348, 26.697200967190277, 6.683399487934403],
[-0.38869601274936194, -24.692060903574472, -10.523950096446182, 52.54530662524236, -43.043928595948266, 1.9830111450545402, 31.361553897012442, 6.352251359568785, -52.458970241927716, 47.910585866530745, 3.3200979771328565, -6.458799979510292],
[1.1291666894532286, 9.271094132928322, 6.556989060122476, -43.04392859594952, 143.87265998173868, 3.1760981912211426, -18.167407777764055, -8.77534428671577, 34.507159990454596, -142.61844510345654, -7.802329075245709, 6.612650220820326],
[-3.6870468844445528, 7.1623515306633605, -9.625990756501434, 1.9830111450544248, 3.1760981912214166, 11.245542721190045, -10.734726485124426, 8.763559147985358, -6.38466830110583, -0.6239692380711215, -9.906889778512198, 3.754796594522557],
[21.031924287810668, -274.93484705013356, -10.75373152123641, 31.361553897012623, -18.167407777762975, -10.73472648512443, 310.54983456415715, 1.7772592924831514, -41.871628668575255, 19.376357147922164, 26.367588957343955, -41.29879922725016],
[-7.1756022689498655, -12.332687485524392, -190.86700516032798, 6.35225135956737, -8.77534428671159, 8.763559147985253, 1.7772592924832473, 191.33311114959903, -14.280853582609833, 13.649480509311815, -25.405070351042873, -3.711403252334159],
[1.9347497361483432, 34.922342675050274, 18.293189963463753, -52.45897024192756, 34.50715999045303, -6.384668301105924, -41.87162866857501, -14.280853582611037, 58.33230116532009, -42.61276327650662, -1.1844745114917652, 6.8685229268349985],
[-3.087805366699339, -13.763167108306925, -12.304818070523693, 47.910585866531854, -142.61844510345614, -0.6239692380708477, 19.376357147923162, 13.649480509316007, -42.61276327650807, 146.4331698152487, 5.863962018279777, -5.434408461560651],
[6.427759272531347, -8.68670634794489, 26.6972009671902, 3.320097977133158, -7.802329075246291, -9.906889778512184, 26.36758895734404, -25.4050703510428, -1.184474511492044, 5.8639620182803815, 25.28208900339451, -5.233405395868846],
[-4.853063131921994, 28.713988094005906, 6.683399487934531, -6.4587999795103075, 6.61265022082007, 3.7547965945225314, -41.29879922725008, -3.71140325233427, 6.8685229268350305, -5.434408461560418, -5.2334053958687905, 15.391629634308694]
];
// ==========================================
// 🔧 DOM要素
// ==========================================
const video = document.getElementById('live-video');
const canvas = document.getElementById('output-canvas');
const ctx = canvas.getContext('2d');
const loadingMessage = document.getElementById('loading-message');
const fpsDisplay = document.getElementById('fps-display');
const recStatus = document.getElementById('recording-status');
const recTimer = document.getElementById('rec-timer');
const btnRecord = document.getElementById('btn-record');
const btnDownload = document.getElementById('btn-download');
const durationInput = document.getElementById('duration-input');
// MT関連DOM
const gestureAlert = document.getElementById('gesture-alert');
const mdValueDisplay = document.getElementById('md-value-display');
const thresholdDisplay = document.getElementById('threshold-display');
const thresholdInput = document.getElementById('threshold-input');
const thresholdSlider = document.getElementById('threshold-slider');
let detector;
let lastTime = 0;
// ✅ デフォルト閾値を50.0に変更
let mdThreshold = 50.0;
// 💾 録画用変数
let isRecording = false;
let recordingStartTime = 0;
let recordedData = [];
let recordDurationMs = 5000;
// 指の定義 (先端, 第二関節(PIP), 付け根(MCP))
const FINGERS = {
THUMB: [4, 3, 2],
INDEX: [8, 6, 5],
MIDDLE: [12, 10, 9],
RING: [16, 14, 13],
PINKY: [20, 18, 17]
};
// ==========================================
// 📐 MT計算ロジック
// ==========================================
// マハラノビス距離計算
function calculateMahalanobisDistance(inputVector) {
// 1. 正規化 (Standardization)
const z = inputVector.map((val, i) => (val - MEAN_VECTOR[i]) / STD_VECTOR[i]);
// 2. MD計算 (1/k * z * A^-1 * z^T)
const k = inputVector.length;
let mdSum = 0;
for (let i = 0; i < k; i++) {
let temp = 0;
for (let j = 0; j < k; j++) {
temp += z[j] * INV_CORR_MATRIX[i][j];
}
mdSum += temp * z[i];
}
return mdSum / k;
}
// ==========================================
// 📐 幾何計算ユーティリティ
// ==========================================
function distance(p1, p2) {
return Math.sqrt((p2.x - p1.x)**2 + (p2.y - p1.y)**2 + ((p2.z||0) - (p1.z||0))**2);
}
function calculate3DAngle(a, b, c) {
const v1 = { x: a.x - b.x, y: a.y - b.y, z: (a.z||0) - (b.z||0) };
const v2 = { x: c.x - b.x, y: c.y - b.y, z: (c.z||0) - (b.z||0) };
const dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;
const mag1 = Math.sqrt(v1.x**2 + v1.y**2 + v1.z**2);
const mag2 = Math.sqrt(v2.x**2 + v2.y**2 + v2.z**2);
let cosine = dot / (mag1 * mag2);
cosine = Math.max(-1.0, Math.min(1.0, cosine));
return (Math.acos(cosine) * 180) / Math.PI;
}
function getCurlRatio(keypoints, tipIdx, midIdx, rootIdx) {
const dist_straight = distance(keypoints[rootIdx], keypoints[tipIdx]);
const dist_curved = distance(keypoints[rootIdx], keypoints[midIdx]) + distance(keypoints[midIdx], keypoints[tipIdx]);
return dist_curved > 0 ? dist_straight / dist_curved : 0;
}
// ==========================================
// 🔄 メインループ
// ==========================================
async function renderLoop(timestamp) {
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
fpsDisplay.textContent = `FPS: ${Math.round(1000 / deltaTime)}`;
const hands = await detector.estimateHands(video, { flipHorizontal: false });
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 録画タイマー処理
if (isRecording) {
const elapsed = Date.now() - recordingStartTime;
recTimer.textContent = (elapsed / 1000).toFixed(2) + "s";
if (elapsed >= recordDurationMs) stopRecording();
}
if (hands && hands.length > 0) {
const hand = hands[0];
const kp = hand.keypoints;
const kp3D = hand.keypoints3D;
const useKp = kp3D || kp;
drawHand(kp);
// 1. 特徴量計算 (12次元)
const angThumb = calculate3DAngle(useKp[4], useKp[2], useKp[1]);
const angIndex = calculate3DAngle(useKp[FINGERS.INDEX[0]], useKp[FINGERS.INDEX[1]], useKp[FINGERS.INDEX[2]]);
const angMiddle = calculate3DAngle(useKp[FINGERS.MIDDLE[0]], useKp[FINGERS.MIDDLE[1]], useKp[FINGERS.MIDDLE[2]]);
const angRing = calculate3DAngle(useKp[FINGERS.RING[0]], useKp[FINGERS.RING[1]], useKp[FINGERS.RING[2]]);
const angPinky = calculate3DAngle(useKp[FINGERS.PINKY[0]], useKp[FINGERS.PINKY[1]], useKp[FINGERS.PINKY[2]]);
const rThumb = getCurlRatio(useKp, ...FINGERS.THUMB);
const rIndex = getCurlRatio(useKp, ...FINGERS.INDEX);
const rMiddle = getCurlRatio(useKp, ...FINGERS.MIDDLE);
const rRing = getCurlRatio(useKp, ...FINGERS.RING);
const rPinky = getCurlRatio(useKp, ...FINGERS.PINKY);
const wristZ = (kp3D && kp3D[0] && kp3D[0].z !== undefined) ? kp3D[0].z : (kp[0].z || 0);
const indexTipZ = (kp3D && kp3D[8] && kp3D[8].z !== undefined) ? kp3D[8].z : (kp[8].z || 0);
// 2. MD計算
// 順序はJSONのcolumnsに合わせる: Angles(T,I,M,R,P) -> Ratios(T,I,M,R,P) -> Wrist_Z -> IndexTip_Z
const inputVector = [
angThumb, angIndex, angMiddle, angRing, angPinky,
rThumb, rIndex, rMiddle, rRing, rPinky,
wristZ, indexTipZ
];
const mdValue = calculateMahalanobisDistance(inputVector);
// 3. UI更新 (MD値)
mdValueDisplay.textContent = mdValue.toFixed(2);
mdValueDisplay.style.color = mdValue <= mdThreshold ? '#00ff00' : '#ffffff';
// 4. ジェスチャー判定
if (mdValue <= mdThreshold) {
gestureAlert.style.display = 'block';
canvas.style.border = "4px solid red";
} else {
gestureAlert.style.display = 'none';
canvas.style.border = "none";
}
// 5. 録画中ならデータ保存
if (isRecording) {
const row = [
Date.now() - recordingStartTime,
...inputVector.map(v => v.toFixed(6)), // 特徴量全て
mdValue.toFixed(4) // MD値も保存
];
recordedData.push(row);
}
} else {
gestureAlert.style.display = 'none';
mdValueDisplay.textContent = '--';
canvas.style.border = "none";
}
animationFrameId = requestAnimationFrame(renderLoop);
}
let animationFrameId;
// 描画ヘルパー
function drawHand(keypoints) {
ctx.fillStyle = '#00FF00';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
for(let p of keypoints) {
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, 2 * Math.PI);
ctx.fill();
}
const connections = [[0,1],[1,2],[2,3],[3,4],[0,5],[5,6],[6,7],[7,8],[0,9],[9,10],[10,11],[11,12],[0,13],[13,14],[14,15],[15,16],[0,17],[17,18],[18,19],[19,20]];
ctx.beginPath();
for(let [i, j] of connections) {
ctx.moveTo(keypoints[i].x, keypoints[i].y);
ctx.lineTo(keypoints[j].x, keypoints[j].y);
}
ctx.stroke();
}
// ==========================================
// 🎮 イベント & UI制御
// ==========================================
// 閾値変更イベント
function updateThreshold(val) {
const num = parseFloat(val);
if (!isNaN(num)) {
mdThreshold = num;
thresholdDisplay.textContent = num.toFixed(1);
thresholdInput.value = num;
thresholdSlider.value = num;
}
}
thresholdInput.addEventListener('input', (e) => updateThreshold(e.target.value));
thresholdSlider.addEventListener('input', (e) => updateThreshold(e.target.value));
// 録画開始
function startRecording() {
if (isRecording) return;
const inputVal = parseInt(durationInput.value, 10);
recordDurationMs = (isNaN(inputVal) || inputVal < 1) ? 5000 : inputVal * 1000;
recordedData = [];
isRecording = true;
recordingStartTime = Date.now();
recStatus.style.display = 'block';
btnRecord.disabled = true;
btnRecord.classList.add('bg-gray-400', 'cursor-not-allowed');
btnRecord.innerHTML = '<span>⏳ 記録中...</span>';
btnDownload.classList.add('hidden');
}
// 録画停止
function stopRecording() {
isRecording = false;
recStatus.style.display = 'none';
btnRecord.disabled = false;
btnRecord.classList.remove('bg-gray-400', 'cursor-not-allowed');
btnRecord.innerHTML = '<span>⏺ 記録開始</span>';
if (recordedData.length > 0) {
btnDownload.classList.remove('hidden');
btnDownload.disabled = false;
btnDownload.innerHTML = `<span>📥 CSV保存 (${recordedData.length}件)</span>`;
}
}
// CSVダウンロード
function downloadCSV(data) {
const headers = [
"Timestamp",
"Thumb_Angle", "Index_Angle", "Middle_Angle", "Ring_Angle", "Pinky_Angle",
"Thumb_Ratio", "Index_Ratio", "Middle_Ratio", "Ring_Ratio", "Pinky_Ratio",
"Wrist_Z", "IndexTip_Z", "MD_Value"
];
const csvContent = [headers.join(","), ...data.map(row => row.join(","))].join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const now = new Date();
link.href = url;
link.download = `mt_gesture_${now.getHours()}${now.getMinutes()}${now.getSeconds()}.csv`;
link.click();
}
btnRecord.addEventListener('click', startRecording);
btnDownload.addEventListener('click', () => downloadCSV(recordedData));
window.addEventListener('keydown', (e) => {
if ((e.code === 'Space' || e.key === ' ') && !e.repeat && document.activeElement.tagName !== 'INPUT') {
e.preventDefault();
startRecording();
}
});
// ==========================================
// 🚀 初期化
// ==========================================
async function init() {
try {
await tf.setBackend('webgl');
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
video.srcObject = stream;
await new Promise(r => video.onloadeddata = r);
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const model = handPoseDetection.SupportedModels.MediaPipeHands;
detector = await handPoseDetection.createDetector(model, {
runtime: 'mediapipe',
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/hands',
modelType: 'full',
maxHands: 1
});
loadingMessage.style.display = 'none';
requestAnimationFrame(renderLoop);
} catch(e) {
alert("起動エラー: " + e.message);
console.error(e);
}
}
init();
</script>
</body>
</html>
結果:驚くほど高精度!
このMTシステムを実装して動かしてみたところ、見事に中指だけを検出できるようになりました。
✅ 右手で学習して、左手でも検知できた
面白かったのが、右手でデータ取得・学習を行ったモデルなのに、左手で中指を立てても検知できた点です。
- 画像認識(YOLO)なら、左右の手は「別物」として学習が必要
- しかし、今回は「関節の角度」という抽象化されたパラメータを使用
- 人間が中指を立てる時の指の曲げ具合や、他の指との相関関係(小指や薬指が曲がっている等)は、左右の手で共通していたため、汎用的なモデルになった
考察:伝統的な統計手法の底力
最近は「とりあえずDeep Learning」「とりあえずLLM」となりがちです。
しかし、今回のような**「特定の決まった動作」**を検出する場合、ブラックボックスな巨大モデルを使うよりも、MTシステムのような統計的手法(数理モデル)の方が、計算コストも圧倒的に低く、かつ挙動が理解しやすいと痛感しました。
行列計算を一回するだけなので、CPU負荷もほとんどありません。PCをシャットダウンさせるためのプログラムがPCを重くしては本末転倒ですからね。
おわりに
YOLOで失敗した時はどうなるかと思いましたが、結果的に「MediaPipe(最新AI)」×「MTシステム(伝統的な統計手法)」の組み合わせで、非常にロバストな「F**k Sign検出器」が完成しました。
次回は、この検出器にNode.jsの child_process.exec("shutdown /s /t 0") などを紐付けて、真の**「怒りのシャットダウンシステム」**を完成させたいと思います。
