// app.js // DOM(HTML)이 모두 로드되었을 때 실행 document.addEventListener('DOMContentLoaded', () => { // 탭 버튼과 콘텐츠 요소를 가져옵니다. const tabVideo = document.getElementById('tab-video'); const tabModels = document.getElementById('tab-models'); const contentVideo = document.getElementById('content-video'); const contentModels = document.getElementById('content-models'); // 비디오 탭 클릭 시 if (tabVideo && tabModels) { // 탭 요소가 있는지 확인 tabVideo.addEventListener('click', () => { // 버튼 활성화 tabVideo.classList.add('active'); tabModels.classList.remove('active'); // 콘텐츠 표시 contentVideo.classList.add('active'); contentModels.classList.remove('active'); }); } // AI 모델 탭 클릭 시 if (tabVideo && tabModels) { // 탭 요소가 있는지 확인 tabModels.addEventListener('click', () => { // 버튼 활성화 tabModels.classList.add('active'); tabVideo.classList.remove('active'); // 콘텐츠 표시 contentModels.classList.add('active'); contentVideo.classList.remove('active'); // [추가] AI 모델 탭을 클릭할 때 목록 새로고침 loadModelList(); }); } // ========== AI 모델 목록 로드 함수 ========== function loadModelList() { // 테이블 본문(tbody)을 선택 const tableBody = document.querySelector('.model-table tbody'); if (!tableBody) return; // 테이블이 없으면 중단 console.log('Loading model list...'); fetch('/list-models') // server.js에 추가한 엔드포인트 .then(response => response.json()) .then(models => { // models = { "OBJDET": { "file": "...", "version": "..." }, ... } // 테이블의 모든 'data-role' 행을 순회 tableBody.querySelectorAll('tr[data-role]').forEach(row => { const role = row.dataset.role; // e.g., "OBJDET" const modelData = models[role]; // 해당 역할의 모델 데이터 (없으면 undefined) const fileCell = row.querySelector('.model-filename'); const versionCell = row.querySelector('.model-version'); const deleteButton = row.querySelector('.btn-delete'); // [수정] 삭제 버튼 선택 if (modelData) { // 일치하는 파일이 있으면, 파일명과 버전 업데이트 if (fileCell) fileCell.textContent = modelData.file; if (versionCell) versionCell.textContent = modelData.version; // [수정] 삭제 버튼 표시 if (deleteButton) deleteButton.classList.remove('hidden'); } else { // 일치하는 파일이 없으면, 기본값 '-'으로 설정 if (fileCell) fileCell.textContent = '-'; if (versionCell) versionCell.textContent = '-'; // [수정] 삭제 버튼 숨김 if (deleteButton) deleteButton.classList.add('hidden'); } }); }) .catch(error => { console.error('모델 목록 로드 실패:', error); alert('모델 목록을 불러오는 데 실패했습니다.'); }); } // ========== AI 모델 목록 로드 함수 끝 ========== // ========== [추가됨] 모델 삭제 요청 함수 ========== function deleteModelFile(filename) { console.log(`Requesting deletion of: ${filename}`); fetch('/delete-model', { // server.js에 추가할 엔드포인트 method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({filename: filename}) // { "filename": "CUUVA_..." } }) .then(response => response.json()) .then(data => { if (data.status === 'success') { alert(`파일이 성공적으로 삭제되었습니다: ${filename}`); loadModelList(); // 삭제 성공 시 목록 새로고침 } else { alert(`삭제 실패: ${data.message}`); } }) .catch(error => { console.error('Delete error:', error); alert('삭제 중 오류가 발생했습니다.'); }); } // ========== 모델 삭제 요청 함수 끝 ========== // ========== AI 모델 업로드/관리 로직 시작 ========== if (contentModels) { // 전역 업로드 관련 요소 가져오기 const globalFileInput = document.getElementById('global-file-input'); const fileNameDisplay = document.getElementById('file-name-display'); const globalUploadButton = document.getElementById('global-upload-button'); // 새로고침 버튼 요소 const refreshButton = contentModels.querySelector('.refresh-button'); // [추가] 모델 테이블 본문 (이벤트 위임용) const tableBody = contentModels.querySelector('.model-table tbody'); // '찾아보기'로 파일 선택 시 if (globalFileInput && fileNameDisplay) { globalFileInput.addEventListener('change', () => { if (globalFileInput.files.length > 0) { const file = globalFileInput.files[0]; // 파일 확장자 확인 if (!file.name.endsWith('.aiwbin')) { alert('잘못된 파일 형식입니다. .aiwbin 파일만 선택할 수 있습니다.'); globalFileInput.value = ''; // 파일 선택 초기화 fileNameDisplay.textContent = '선택된 파일 없음'; } else { // 파일명 표시 fileNameDisplay.textContent = file.name; } } else { fileNameDisplay.textContent = '선택된 파일 없음'; } }); } // '업로드' 버튼 클릭 시 if (globalUploadButton && globalFileInput) { globalUploadButton.addEventListener('click', () => { // 파일이 선택되었는지 확인 if (globalFileInput.files.length === 0) { alert('먼저 .aiwbin 파일을 선택해주세요.'); return; } const file = globalFileInput.files[0]; // (이중 확인) 파일 확장자 확인 if (!file.name.endsWith('.aiwbin')) { alert('잘못된 파일 형식입니다. .aiwbin 파일만 업로드할 수 있습니다.'); globalFileInput.value = ''; fileNameDisplay.textContent = '선택된 파일 없음'; return; } // FormData 객체 생성 const formData = new FormData(); formData.append('modelFile', file); // 'modelFile'은 server.js와 일치해야 함 // 업로드 중 버튼 비활성화 globalUploadButton.disabled = true; globalUploadButton.textContent = '업로드 중...'; // Fetch API를 사용하여 파일 업로드 fetch('/upload-model', { method: 'POST', body: formData }) .then(response => response.json()) .then(data => { if (data.status === 'success') { alert(`업로드 성공!\n파일: ${file.name}`); globalFileInput.value = ''; // 파일 선택 초기화 fileNameDisplay.textContent = '선택된 파일 없음'; // 업로드 성공 시 모델 목록 새로고침 loadModelList(); } else { alert(`업로드 실패: ${data.message}`); } }) .catch(error => { console.error('Upload error:', error); alert('업로드 중 오류가 발생했습니다.'); }) .finally(() => { // 버튼 다시 활성화 globalUploadButton.disabled = false; globalUploadButton.textContent = '업로드'; }); }); } // '새로고침' 버튼 클릭 시 if (refreshButton) { refreshButton.addEventListener('click', () => { loadModelList(); // 목록 새로고침 함수 호출 }); } // ========== [추가됨] 삭제 버튼 이벤트 리스너 (이벤트 위임) ========== if (tableBody) { tableBody.addEventListener('click', (event) => { // 클릭된 요소가 'btn-delete' 클래스를 가지고 있는지 확인 if (event.target.classList.contains('btn-delete')) { // 클릭된 버튼에서 가장 가까운 (행)을 찾습니다. const row = event.target.closest('tr'); if (!row) return; // 행에서 파일명 셀(.model-filename)을 찾아 파일명을 가져옵니다. const fileCell = row.querySelector('.model-filename'); const filename = fileCell ? fileCell.textContent : null; // 파일명이 없거나 '-' 이면 (파일이 할당되지 않음) 중단 if (!filename || filename === '-') { alert('삭제할 파일이 없습니다.'); return; } // 행의 두 번째 셀()에서 역할 텍스트를 가져옵니다. const roleText = row.cells[1] ? row.cells[1].textContent : '알 수 없는 역할'; // 사용자에게 삭제 확인을 받습니다. if (confirm(`정말로 이 모델 파일을 삭제하시겠습니까?\n\n역할: ${roleText}\n파일: ${filename}`)) { // 확인 시, 삭제 함수 호출 deleteModelFile(filename); } } }); } // ========== 삭제 버튼 이벤트 리스너 끝 ========== } // ========== AI 모델 업로드/관리 로직 끝 ========== // ========== 로그아웃 버튼 처리 ========== const logoutButton = document.getElementById('logout-button'); if (logoutButton) { // 로그아웃 버튼이 있는지 확인 logoutButton.addEventListener('click', () => { console.log('Logout clicked'); // [수정됨] 사용자에게 확인을 받습니다. if (confirm('정말로 로그아웃하시겠습니까?')) { // 확인을 누르면 로그인 페이지(루트 '/')로 이동 window.location.href = '/'; } // 취소를 누르면 아무 일도 일어나지 않습니다. }); } // ========== 로그아웃 버튼 처리 끝 ========== // ========== dashboard.html 스크립트 추가 시작 ========== const uri = "ws://10.10.11.246:8765"; // 필요하면 여기만 수정 const imgEl = document.getElementById("frame"); const statusEl = document.getElementById("status"); const frameInfoEl = document.getElementById("frame-info"); const detListEl = document.getElementById("det-list"); const bboxContainerEl = document.getElementById("bbox-container"); const modelContainerEl = document.getElementById("current-model-container"); // [추가] 오버레이 표시용 요소 const modelDisplayEl = document.getElementById("current-model-display"); const fpsDisplayEl = document.getElementById("fps-display"); // [추가] 클래스별 색상 팔레트 (원하는 색상으로 수정 가능) const CLASS_COLORS = [ '#FF3838', // 0: Red '#38BFFF', // 1: Blue '#38FF4E', // 2: Green '#FFF238', // 3: Yellow '#FF9D38', // 4: Orange '#C538FF', // 5: Purple '#FF38C5', // 6: Pink '#FFFFFF' // 7: White ]; // [추가] FPS 계산용 변수 let frameCount = 0; let lastFpsCheckTime = performance.now(); let lastFrameMeta = null; if (imgEl && statusEl && frameInfoEl && detListEl && bboxContainerEl && modelContainerEl) { // 모델 변경 이벤트 리스너 modelContainerEl.addEventListener('change', (event) => { if (event.target && event.target.name === 'current-model') { // [추가] 모델 이름 즉시 업데이트 updateModelDisplay(); const selectedModel = event.target.value; console.log(`Model changed to: ${selectedModel}`); fetch('/set-model', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({model: selectedModel}), }) .then(response => response.json()) .then(data => { console.log('Server response:', data); if (data.status === 'success') { logStatus(`모델 변경 완료: ${selectedModel}`); } else { logStatus(`모델 변경 실패: ${data.message}`, true); } }) .catch(error => { console.error('Error setting model:', error); logStatus('모델 변경 중 오류 발생', true); }); } }); // 박스 컨테이너 위치/크기 조절 함수 function updateBboxContainerPosition() { if (!imgEl || !bboxContainerEl) return; const top = imgEl.offsetTop; const left = imgEl.offsetLeft; const width = imgEl.offsetWidth; const height = imgEl.offsetHeight; bboxContainerEl.style.top = `${top}px`; bboxContainerEl.style.left = `${left}px`; bboxContainerEl.style.width = `${width}px`; bboxContainerEl.style.height = `${height}px`; } function logStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.className = "status" + (isError ? " err" : ""); } function showFrameMeta(meta) { frameInfoEl.textContent = ` | FRAME ch=${meta.ch} ts=${meta.ts_us} w=${meta.w} h=${meta.h}`; } // [추가] 선택된 AI 모델 이름을 상단 우측에 표시하는 함수 function updateModelDisplay() { if (!modelContainerEl || !modelDisplayEl) return; // 현재 선택된 라디오 버튼을 찾습니다. const checkedRadio = modelContainerEl.querySelector('input[name="current-model"]:checked'); if (checkedRadio) { // 해당 라디오 버튼의 ID로 label을 찾습니다. const label = modelContainerEl.querySelector(`label[for="${checkedRadio.id}"]`); if (label) { modelDisplayEl.textContent = label.textContent; // "군중 위험 인식" } else { modelDisplayEl.textContent = checkedRadio.value; // "CROWD" (대체) } } else { modelDisplayEl.textContent = "모델 선택 안됨"; } } // Detections 표시 함수 (수정됨) function showDetections(meta) { const items = meta.items || []; let lines = []; lines.push(`DET ch=${meta.ch} seq=${meta.seq} ts=${meta.ts_us} cnt=${items.length}`); bboxContainerEl.innerHTML = ""; if (!imgEl || !lastFrameMeta) { // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정 items.forEach((it, i) => { // x1, y1, x2, y2 -> x, y, w, h const x = it.x1; const y = it.y1; const w = it.x2 - it.x1; const h = it.y2 - it.y1; lines.push( `#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용 + ` x=${x.toFixed(3)} y=${y.toFixed(3)}` + ` w=${w.toFixed(3)} h=${h.toFixed(3)}` ); }); detListEl.textContent = lines.join("\n"); return; } updateBboxContainerPosition(); const imgWidth = imgEl.clientWidth; const imgHeight = imgEl.clientHeight; const frameWidth = lastFrameMeta.w; const frameHeight = lastFrameMeta.h; if (frameWidth === 0 || frameHeight === 0 || imgWidth === 0 || imgHeight === 0) { // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정 items.forEach((it, i) => { // x1, y1, x2, y2 -> x, y, w, h const x = it.x1; const y = it.y1; const w = it.x2 - it.x1; const h = it.y2 - it.y1; lines.push( `#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용 + ` x=${x.toFixed(3)} y=${y.toFixed(3)}` + ` w=${w.toFixed(3)} h=${h.toFixed(3)}` ); }); detListEl.textContent = lines.join("\n"); return; } const widthRatio = imgWidth / frameWidth; const heightRatio = imgHeight / frameHeight; const ratio = Math.min(widthRatio, heightRatio); const videoDisplayWidth = frameWidth * ratio; const videoDisplayHeight = frameHeight * ratio; const offsetX = (imgWidth - videoDisplayWidth) / 2; const offsetY = (imgHeight - videoDisplayHeight) / 2; // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 및 박스 계산 수정 items.forEach((it, i) => { // x1, y1, x2, y2 -> x, y, w, h const x = it.x1; const y = it.y1; const w = it.x2 - it.x1; const h = it.y2 - it.y1; // [추가] 현재 항목의 클래스(cls) 가져오기 const cls = it.cls; lines.push( `#${i} cls=${cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용 + ` x=${x.toFixed(3)} y=${y.toFixed(3)}` + ` w=${w.toFixed(3)} h=${h.toFixed(3)}` ); // 계산된 x, y, w, h를 사용하여 박스 위치 계산 const boxLeft = (x * ratio) + offsetX; const boxTop = (y * ratio) + offsetY; const boxWidth = w * ratio; const boxHeight = h * ratio; // [추가] 클래스(cls) 값에 따라 색상 결정 // cls를 정수로 변환 (혹시 문자열일 경우 대비) const classIndex = parseInt(cls, 10) || 0; // 모듈러(%) 연산을 사용해 색상 배열을 순환 const color = CLASS_COLORS[classIndex % CLASS_COLORS.length]; const bbox = document.createElement('div'); bbox.className = 'bbox'; bbox.style.left = `${boxLeft}px`; bbox.style.top = `${boxTop}px`; bbox.style.width = `${boxWidth}px`; bbox.style.height = `${boxHeight}px`; // [추가] CSS의 'border' 대신 'borderColor'를 동적으로 설정 // 이렇게 하면 style.css의 'border-width', 'border-style'은 유지됩니다. bbox.style.borderColor = color; bboxContainerEl.appendChild(bbox); }); detListEl.textContent = lines.join("\n"); } // WebSocket 연결 함수 function connect() { const ws = new WebSocket(uri); ws.binaryType = "arraybuffer"; ws.onopen = () => { logStatus(`연결됨: ${uri}`); }; ws.onclose = (ev) => { logStatus(`연결 종료 (code=${ev.code}) 재접속 대기중...`, true); setTimeout(connect, 2000); }; ws.onerror = (err) => { logStatus("WebSocket 오류 발생", true); console.error(err); }; ws.onmessage = (event) => { const data = event.data; if (typeof data === "string") { try { const meta = JSON.parse(data); const t = meta.type; if (t === "frame") { lastFrameMeta = meta; showFrameMeta(meta); } else if (t === "det") { showDetections(meta); } } catch (e) { console.error("JSON parse error:", e, data); } return; } if (data instanceof ArrayBuffer && lastFrameMeta) { const blob = new Blob([data], {type: "image/jpeg"}); const url = URL.createObjectURL(blob); imgEl.onload = () => { URL.revokeObjectURL(url); updateBboxContainerPosition(); // [추가] FPS 계산 frameCount++; const now = performance.now(); const delta = now - lastFpsCheckTime; if (delta >= 1000 && fpsDisplayEl) { // 1초마다 업데이트 const fps = (frameCount * 1000) / delta; fpsDisplayEl.textContent = `${Math.round(fps)} FPS`; frameCount = 0; lastFpsCheckTime = now; } }; imgEl.src = url; } }; } // [추가] 초기 모델 이름 설정 updateModelDisplay(); // WebSocket 연결 시작 connect(); // 창 크기 변경 시 이벤트 window.addEventListener('resize', updateBboxContainerPosition); } // if (요소 확인) 끝 // ========== video-test.html 스크립트 추가 끝 ========== // ========== 페이지 로드 시 모델 목록 즉시 로드 ========== loadModelList(); });