Spaces:
Running
Running
<html lang="ja"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>複合認識システム</title> | |
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@1.3.1/dist/tf.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@teachablemachine/image@0.8/dist/teachablemachine-image.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@teachablemachine/pose@0.8/dist/teachablemachine-pose.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/face_mesh.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script> | |
<style> | |
body { | |
font-family: Arial, sans-serif; | |
margin: 0; | |
padding: 20px; | |
background-color: #f5f5f5; | |
} | |
.container { | |
max-width: 1200px; | |
margin: 0 auto; | |
} | |
.header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 20px; | |
} | |
.camera-container { | |
position: relative; | |
margin-bottom: 20px; | |
border: 2px solid #333; | |
border-radius: 8px; | |
overflow: hidden; | |
} | |
.camera-info { | |
display: flex; | |
justify-content: space-between; | |
background-color: #333; | |
color: white; | |
padding: 10px; | |
} | |
.detection-strength { | |
display: flex; | |
gap: 20px; | |
} | |
.strength-bar { | |
height: 20px; | |
width: 100px; | |
background-color: #ddd; | |
border-radius: 4px; | |
overflow: hidden; | |
} | |
.strength-fill { | |
height: 100%; | |
background-color: #4CAF50; | |
width: 0%; | |
} | |
.tile-container { | |
display: flex; | |
flex-wrap: wrap; | |
gap: 20px; | |
margin-bottom: 20px; | |
} | |
.tile { | |
background-color: white; | |
border-radius: 8px; | |
padding: 15px; | |
box-shadow: 0 2px 5px rgba(0,0,0,0.1); | |
width: calc(33% - 20px); | |
position: relative; | |
} | |
.tile-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 10px; | |
} | |
.tile-title { | |
font-weight: bold; | |
} | |
.delete-btn { | |
background-color: #ff4444; | |
color: white; | |
border: none; | |
border-radius: 4px; | |
padding: 2px 8px; | |
cursor: pointer; | |
} | |
.chart-container { | |
height: 150px; | |
margin-bottom: 10px; | |
display: flex; | |
align-items: flex-end; | |
gap: 5px; | |
} | |
.chart-bar { | |
flex-grow: 1; | |
background-color: #4CAF50; | |
transition: height 0.3s; | |
} | |
.add-tile { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
background-color: #ddd; | |
cursor: pointer; | |
font-size: 24px; | |
} | |
.add-tile:hover { | |
background-color: #ccc; | |
} | |
.modal { | |
display: none; | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0,0,0,0.5); | |
justify-content: center; | |
align-items: center; | |
z-index: 1000; | |
} | |
.modal-content { | |
background-color: white; | |
padding: 20px; | |
border-radius: 8px; | |
width: 400px; | |
} | |
.modal-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 15px; | |
} | |
.close-btn { | |
font-size: 24px; | |
cursor: pointer; | |
} | |
.form-group { | |
margin-bottom: 15px; | |
} | |
label { | |
display: block; | |
margin-bottom: 5px; | |
} | |
input, select { | |
width: 100%; | |
padding: 8px; | |
border: 1px solid #ddd; | |
border-radius: 4px; | |
} | |
.submit-btn { | |
background-color: #4CAF50; | |
color: white; | |
border: none; | |
padding: 10px 15px; | |
border-radius: 4px; | |
cursor: pointer; | |
} | |
.alarm { | |
position: fixed; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(255,0,0,0.3); | |
z-index: 999; | |
display: none; | |
} | |
.eye-status { | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
} | |
.eye-status-label { | |
font-weight: bold; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="header"> | |
<h1>複合認識システム</h1> | |
</div> | |
<div class="camera-container"> | |
<video id="webcam" width="640" height="480" autoplay playsinline></video> | |
<canvas id="output" width="640" height="480"></canvas> | |
<div class="camera-info"> | |
<div class="eye-status"> | |
<span class="eye-status-label">目の状態:</span> | |
<span id="eye-status-text">検出中...</span> | |
</div> | |
<div class="detection-strength"> | |
<div> | |
<div>認識強度</div> | |
<div class="strength-bar"> | |
<div class="strength-fill" id="strength-fill"></div> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<h2>認識モデル設定</h2> | |
<div class="tile-container" id="tile-container"> | |
<!-- タイルはここに動的に追加されます --> | |
</div> | |
<div class="tile add-tile" id="add-tile"> | |
+ | |
</div> | |
</div> | |
<div class="alarm" id="alarm"></div> | |
<div class="modal" id="modal"> | |
<div class="modal-content"> | |
<div class="modal-header"> | |
<h3>新しいモデルを追加</h3> | |
<span class="close-btn" id="close-modal">×</span> | |
</div> | |
<div class="form-group"> | |
<label for="model-type">モデルタイプ</label> | |
<select id="model-type"> | |
<option value="image">画像認識</option> | |
<option value="pose">ポーズ認識</option> | |
</select> | |
</div> | |
<div class="form-group"> | |
<label for="model-id">モデルID (TeachableMachineのURLの最後の部分)</label> | |
<input type="text" id="model-id" placeholder="例: E7pp4SoMG"> | |
</div> | |
<div class="form-group"> | |
<label for="alarm-class">警報を鳴らすクラス名 (カンマ区切り)</label> | |
<input type="text" id="alarm-class" placeholder="例: 危険,警告"> | |
</div> | |
<button class="submit-btn" id="submit-model">追加</button> | |
</div> | |
</div> | |
<audio id="alarm-sound" src="https://assets.mixkit.co/sfx/preview/mixkit-alarm-digital-clock-beep-989.mp3" preload="auto"></audio> | |
<script> | |
// グローバル変数 | |
let faceMesh = null; | |
let eyeStatus = "検出しない"; | |
let eyeDetectionCount = 0; | |
let eyeClosedCount = 0; | |
let eyeOpenCount = 0; | |
let alarmSound = document.getElementById("alarm-sound"); | |
let alarmElement = document.getElementById("alarm"); | |
let modelTiles = []; | |
let activeModels = []; | |
let detectionHistory = []; | |
const HISTORY_SIZE = 50; | |
const ALARM_THRESHOLD = 40; | |
// 目の状態を更新する関数 | |
function updateEyeStatus(newStatus) { | |
const eyeStatusText = document.getElementById("eye-status-text"); | |
if (newStatus !== eyeStatus) { | |
eyeStatus = newStatus; | |
eyeStatusText.textContent = eyeStatus; | |
// 警報条件チェック | |
checkEyeAlarmCondition(); | |
} | |
} | |
// 目の警報条件チェック | |
function checkEyeAlarmCondition() { | |
if (eyeStatus === "検出しない") { | |
// 警報を止める | |
stopAlarm(); | |
return; | |
} | |
// 50回の検出で40回以上条件を満たしたら警報を鳴らす | |
if (eyeDetectionCount >= HISTORY_SIZE) { | |
if (eyeStatus === "目をつぶっている場合" && eyeClosedCount >= ALARM_THRESHOLD) { | |
startAlarm(); | |
} else if (eyeStatus === "目を開けている場合" && eyeOpenCount >= ALARM_THRESHOLD) { | |
startAlarm(); | |
} else { | |
stopAlarm(); | |
} | |
} | |
} | |
// 警報を開始 | |
function startAlarm() { | |
alarmElement.style.display = "block"; | |
alarmSound.currentTime = 0; | |
alarmSound.play().catch(e => console.log("Audio play failed:", e)); | |
} | |
// 警報を停止 | |
function stopAlarm() { | |
alarmElement.style.display = "none"; | |
alarmSound.pause(); | |
} | |
// 目の検出を初期化 | |
async function initEyeDetection() { | |
const videoElement = document.getElementById('webcam'); | |
const canvasElement = document.getElementById('output'); | |
const canvasCtx = canvasElement.getContext('2d'); | |
faceMesh = new FaceMesh({ | |
locateFile: (file) => { | |
return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`; | |
} | |
}); | |
faceMesh.setOptions({ | |
maxNumFaces: 1, | |
refineLandmarks: true, | |
minDetectionConfidence: 0.5, | |
minTrackingConfidence: 0.5 | |
}); | |
faceMesh.onResults((results) => { | |
canvasCtx.save(); | |
canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height); | |
canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height); | |
if (results.multiFaceLandmarks) { | |
for (const landmarks of results.multiFaceLandmarks) { | |
// 左目のEAR (Eye Aspect Ratio) を計算 | |
const leftEAR = getEAR( | |
landmarks, | |
159, 145, 133, 33 // 左目のlandmarkインデックス | |
); | |
// 右目のEARを計算 | |
const rightEAR = getEAR( | |
landmarks, | |
386, 374, 362, 263 // 右目のlandmarkインデックス | |
); | |
// 両目の平均EAR | |
const ear = (leftEAR + rightEAR) / 2; | |
// EARに基づいて目の状態を判定 | |
const EAR_THRESHOLD = 0.25; | |
const isBlinking = ear < EAR_THRESHOLD; | |
// 検出強度を更新 | |
updateDetectionStrength(ear); | |
// 目の状態を更新 | |
if (eyeStatus !== "検出しない") { | |
eyeDetectionCount = (eyeDetectionCount + 1) % HISTORY_SIZE; | |
if (isBlinking) { | |
eyeClosedCount = Math.min(eyeClosedCount + 1, HISTORY_SIZE); | |
if (eyeOpenCount > 0) eyeOpenCount--; | |
if (eyeStatus === "目をつぶっている場合") { | |
updateEyeStatus("目をつぶっている場合"); | |
} | |
} else { | |
eyeOpenCount = Math.min(eyeOpenCount + 1, HISTORY_SIZE); | |
if (eyeClosedCount > 0) eyeClosedCount--; | |
if (eyeStatus === "目を開けている場合") { | |
updateEyeStatus("目を開けている場合"); | |
} | |
} | |
} | |
} | |
} | |
canvasCtx.restore(); | |
}); | |
const camera = new Camera(videoElement, { | |
onFrame: async () => { | |
await faceMesh.send({image: videoElement}); | |
}, | |
width: 640, | |
height: 480 | |
}); | |
camera.start(); | |
} | |
// EAR (Eye Aspect Ratio) を計算する関数 | |
function getEAR(landmarks, topIdx, bottomIdx, leftIdx, rightIdx) { | |
const vertical = Math.hypot( | |
landmarks[topIdx].x - landmarks[bottomIdx].x, | |
landmarks[topIdx].y - landmarks[bottomIdx].y | |
); | |
const horizontal = Math.hypot( | |
landmarks[leftIdx].x - landmarks[rightIdx].x, | |
landmarks[leftIdx].y - landmarks[rightIdx].y | |
); | |
return vertical / horizontal; | |
} | |
// 検出強度を更新 | |
function updateDetectionStrength(ear) { | |
const strengthFill = document.getElementById("strength-fill"); | |
// EARが0.15-0.45の範囲で0-100%にマッピング | |
const strength = Math.min(Math.max((ear - 0.15) / (0.45 - 0.15) * 100, 0), 100); | |
strengthFill.style.width = `${strength}%`; | |
// 色を変更 (緑→黄→赤) | |
if (strength > 50) { | |
strengthFill.style.backgroundColor = "#4CAF50"; // 緑 | |
} else if (strength > 20) { | |
strengthFill.style.backgroundColor = "#FFC107"; // 黄 | |
} else { | |
strengthFill.style.backgroundColor = "#F44336"; // 赤 | |
} | |
} | |
// モデルをロードしてタイルに追加 | |
async function addModelTile(modelType, modelId, alarmClasses) { | |
const tileContainer = document.getElementById("tile-container"); | |
// タイル要素を作成 | |
const tile = document.createElement("div"); | |
tile.className = "tile"; | |
tile.dataset.modelType = modelType; | |
tile.dataset.modelId = modelId; | |
tile.dataset.alarmClasses = alarmClasses; | |
// タイルヘッダー | |
const tileHeader = document.createElement("div"); | |
tileHeader.className = "tile-header"; | |
const tileTitle = document.createElement("div"); | |
tileTitle.className = "tile-title"; | |
tileTitle.textContent = `${modelType === 'image' ? '画像認識' : 'ポーズ認識'}: ${modelId}`; | |
const deleteBtn = document.createElement("button"); | |
deleteBtn.className = "delete-btn"; | |
deleteBtn.textContent = "×"; | |
deleteBtn.onclick = () => { | |
tileContainer.removeChild(tile); | |
modelTiles = modelTiles.filter(t => t !== tile); | |
activeModels = activeModels.filter(m => m.tile !== tile); | |
}; | |
tileHeader.appendChild(tileTitle); | |
tileHeader.appendChild(deleteBtn); | |
tile.appendChild(tileHeader); | |
// チャートコンテナ | |
const chartContainer = document.createElement("div"); | |
chartContainer.className = "chart-container"; | |
tile.appendChild(chartContainer); | |
// モデル情報 | |
const modelInfo = document.createElement("div"); | |
modelInfo.textContent = `警報クラス: ${alarmClasses}`; | |
tile.appendChild(modelInfo); | |
// タイルを追加 | |
tileContainer.appendChild(tile); | |
modelTiles.push(tile); | |
// モデルをロード | |
const modelUrl = `https://storage.googleapis.com/tm-model/${modelId}/`; | |
try { | |
let model; | |
let maxPredictions; | |
if (modelType === 'image') { | |
model = await tmImage.load(`${modelUrl}model.json`, `${modelUrl}metadata.json`); | |
maxPredictions = model.getTotalClasses(); | |
// チャート用のバーを作成 | |
for (let i = 0; i < maxPredictions; i++) { | |
const bar = document.createElement("div"); | |
bar.className = "chart-bar"; | |
bar.dataset.classIndex = i; | |
chartContainer.appendChild(bar); | |
} | |
// Webcamをセットアップ | |
const webcam = new tmImage.Webcam(200, 200, true); | |
await webcam.setup(); | |
await webcam.play(); | |
// 予測ループ | |
const predictLoop = async () => { | |
const prediction = await model.predict(webcam.canvas); | |
// 警報条件チェック | |
let shouldAlarm = false; | |
// チャートを更新 | |
for (let i = 0; i < maxPredictions; i++) { | |
const probability = prediction[i].probability; | |
const className = prediction[i].className; | |
// バーの高さを更新 | |
const bars = chartContainer.querySelectorAll(".chart-bar"); | |
if (bars[i]) { | |
bars[i].style.height = `${probability * 100}%`; | |
// 警報クラスの場合は色を赤に | |
if (alarmClasses.split(',').includes(className)) { | |
bars[i].style.backgroundColor = probability > 0.5 ? "#F44336" : "#4CAF50"; | |
if (probability > 0.5) { | |
shouldAlarm = true; | |
} | |
} else { | |
bars[i].style.backgroundColor = "#4CAF50"; | |
} | |
} | |
} | |
// 警報条件を履歴に追加 | |
detectionHistory.push(shouldAlarm); | |
if (detectionHistory.length > HISTORY_SIZE) { | |
detectionHistory.shift(); | |
} | |
// 警報条件チェック | |
if (detectionHistory.length === HISTORY_SIZE) { | |
const alarmCount = detectionHistory.filter(Boolean).length; | |
if (alarmCount >= ALARM_THRESHOLD) { | |
startAlarm(); | |
} else { | |
stopAlarm(); | |
} | |
} | |
requestAnimationFrame(predictLoop); | |
}; | |
predictLoop(); | |
activeModels.push({ | |
tile, | |
model, | |
webcam, | |
predictLoop | |
}); | |
} else if (modelType === 'pose') { | |
model = await tmPose.load(`${modelUrl}model.json`, `${modelUrl}metadata.json`); | |
maxPredictions = model.getTotalClasses(); | |
// チャート用のバーを作成 | |
for (let i = 0; i < maxPredictions; i++) { | |
const bar = document.createElement("div"); | |
bar.className = "chart-bar"; | |
bar.dataset.classIndex = i; | |
chartContainer.appendChild(bar); | |
} | |
// Webcamをセットアップ | |
const webcam = new tmPose.Webcam(200, 200, true); | |
await webcam.setup(); | |
await webcam.play(); | |
// 予測ループ | |
const predictLoop = async () => { | |
const { pose, posenetOutput } = await model.estimatePose(webcam.canvas); | |
const prediction = await model.predict(posenetOutput); | |
// 警報条件チェック | |
let shouldAlarm = false; | |
// チャートを更新 | |
for (let i = 0; i < maxPredictions; i++) { | |
const probability = prediction[i].probability; | |
const className = prediction[i].className; | |
// バーの高さを更新 | |
const bars = chartContainer.querySelectorAll(".chart-bar"); | |
if (bars[i]) { | |
bars[i].style.height = `${probability * 100}%`; | |
// 警報クラスの場合は色を赤に | |
if (alarmClasses.split(',').includes(className)) { | |
bars[i].style.backgroundColor = probability > 0.5 ? "#F44336" : "#4CAF50"; | |
if (probability > 0.5) { | |
shouldAlarm = true; | |
} | |
} else { | |
bars[i].style.backgroundColor = "#4CAF50"; | |
} | |
} | |
} | |
// 警報条件を履歴に追加 | |
detectionHistory.push(shouldAlarm); | |
if (detectionHistory.length > HISTORY_SIZE) { | |
detectionHistory.shift(); | |
} | |
// 警報条件チェック | |
if (detectionHistory.length === HISTORY_SIZE) { | |
const alarmCount = detectionHistory.filter(Boolean).length; | |
if (alarmCount >= ALARM_THRESHOLD) { | |
startAlarm(); | |
} else { | |
stopAlarm(); | |
} | |
} | |
requestAnimationFrame(predictLoop); | |
}; | |
predictLoop(); | |
activeModels.push({ | |
tile, | |
model, | |
webcam, | |
predictLoop | |
}); | |
} | |
} catch (error) { | |
console.error("モデルのロードに失敗しました:", error); | |
tileTitle.textContent += " (ロード失敗)"; | |
} | |
} | |
// モーダルを開く | |
document.getElementById("add-tile").addEventListener("click", () => { | |
document.getElementById("modal").style.display = "flex"; | |
}); | |
// モーダルを閉じる | |
document.getElementById("close-modal").addEventListener("click", () => { | |
document.getElementById("modal").style.display = "none"; | |
}); | |
// モデルを追加 | |
document.getElementById("submit-model").addEventListener("click", () => { | |
const modelType = document.getElementById("model-type").value; | |
const modelId = document.getElementById("model-id").value.trim(); | |
const alarmClasses = document.getElementById("alarm-class").value.trim(); | |
if (modelId && alarmClasses) { | |
addModelTile(modelType, modelId, alarmClasses); | |
document.getElementById("modal").style.display = "none"; | |
// フォームをリセット | |
document.getElementById("model-id").value = ""; | |
document.getElementById("alarm-class").value = ""; | |
} else { | |
alert("モデルIDと警報クラスを入力してください"); | |
} | |
}); | |
// 目の状態選択 (デモ用) | |
function setupEyeStatusSelector() { | |
const eyeStatusText = document.getElementById("eye-status-text"); | |
eyeStatusText.addEventListener("click", () => { | |
const currentStatus = eyeStatusText.textContent; | |
let newStatus; | |
if (currentStatus === "検出しない") { | |
newStatus = "目をつぶっている場合"; | |
} else if (currentStatus === "目をつぶっている場合") { | |
newStatus = "目を開けている場合"; | |
} else { | |
newStatus = "検出しない"; | |
} | |
updateEyeStatus(newStatus); | |
eyeDetectionCount = 0; | |
eyeClosedCount = 0; | |
eyeOpenCount = 0; | |
}); | |
} | |
// 初期化 | |
async function init() { | |
await initEyeDetection(); | |
setupEyeStatusSelector(); | |
// デフォルトで目の状態を「検出しない」に設定 | |
updateEyeStatus("検出しない"); | |
} | |
// ページ読み込み時に初期化 | |
window.onload = init; | |
</script> | |
</body> | |
</html> |