0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PCに中指を立てたらシャットダウンする『アンガーマネジメントシステム』を作ってみた(MediaPipe × MT法)

Last updated at Posted at 2025-12-28

はじめに

こんにちは。
先日、大学のサークル仲間と久しぶりに飲み会がありまして、「最近コード書いてないなー」なんて話をしていたんです。そこで話題になったのが、「今はAI(Geminiなど)に頼めば、大抵のプログラムは書けちゃうよね」 ということ。

そこで、お酒を飲みながらすごく冷静な頭で考えたのが、くだらない願望を形にするこのプログラムです。

「PCに向かって中指を立てたら、それを検知してPCをシャットダウンさせたい」

イライラした時、物理的にPCを壊す前にシステムを落とす。そんなアンガーマネジメント(?)システムです。

Gemini_Generated_Image_rhfebarhfebarhfe.png

出典:2025年 M-1グランプリ 決勝1本目 ヤーレンズ(Geminiにより生成)

今回はその第一歩として、 「中指を立てた状態(Fk Sign)を正確に検出する」までの、意外と険しかった道のりを紹介します。

※本記事は技術的な知見の共有を目的としており、特定の他者を攻撃する意図はありません

試行錯誤1:YOLOでの画像認識(失敗)

最初に考えたのは、王道の物体検出モデル「YOLO (You Only Look Once)」を使う方法です。

  1. Webカメラで中指を立てている動画を撮影
  2. フレームごとに画像を切り出し
  3. ラベリングしてYOLOに学習させる
    という手順を踏みました。

結果:失敗 ❌️

全然うまくいきませんでした。
冷静に考えると、「中指を立てた手」と「ただの拳(グー)」は、画像における画素の占有領域(バウンディングボックス)が9割以上同じなんです。
💡 YOLOはテクスチャ(見た目)や形状の輪郭を学習しますが、今回の課題には不向きでした。
逆に、後に採用することになるMTシステム×MediaPipeの手法は『骨格の構造的関係性』を見るため、照明条件や手の色、背景に依存しにくいという利点があります。この時点ではそこに気づけず、画素情報だけで戦おうとしたのが敗因でした。

指一本の有無だけで判断させるには、YOLOのような物体検出モデルでは大量のデータセットと厳密なチューニングが必要で、個人の趣味開発には不向きでした。

解決策:MediaPipe × MTシステム(異常検知)

YOLOのような「画像認識」ではなく、GoogleのMediaPipeで手の形状(ランドマーク)を取得し、その数値データを解析する方針に切り替えました。

そこで採用したのが、MTシステム(マハラノビス・タグチ・メソッド) です。

MTシステムとは?
主に製造業の異常検知などで使われる手法です。
データの「相関」を考慮したマハラノビス距離を計算することで、**「正常なデータ群の中心から、どれくらい離れているか(どれくらい異常か)」**を数値化できます。

今回のアイデア

  • 単位空間(基準データ): 手の甲をカメラに向け、中指をのみ立てた状態
  • 信号データ(入力): 今カメラに映っている手
  • 判定: マハラノビス距離がある閾値を超えたら「異常(=普通の手の状態や、様々な手の形)」とみなす

実装のポイント

  1. 2Dではなく「3D座標」を使う
    MediaPipeは手のランドマークの x, y (2D) だけでなく z (深度) も取得できます。
    今回はこの3D空間での関節角度などのパラメータを使用しました。これは2D画像だけで判定しようとしたYOLOの反省から来ています。

  2. 特徴量の設計(ここが重要!)
    手首の座標や位置情報(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]

  3. マハラノビス距離の計算

抽出した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") などを紐付けて、真の**「怒りのシャットダウンシステム」**を完成させたいと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?