// app.js document.addEventListener('DOMContentLoaded', () => { // ================================================= // 0. 로그아웃 로직 // ================================================= const logoutBtn = document.getElementById('logout-button'); if (logoutBtn) { logoutBtn.addEventListener('click', () => { if (confirm('정말로 로그아웃 하시겠습니까?')) { // 로그인 페이지(루트)로 이동 window.location.href = '/'; } }); } // ================================================= // 1. 탭 전환 로직 // ================================================= 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'); }); tabModels.addEventListener('click', () => { tabModels.classList.add('active'); tabVideo.classList.remove('active'); contentModels.classList.add('active'); contentVideo.classList.remove('active'); loadModelList(); loadPoiList(); }); } // ================================================= // 2. AI 모델 관리 로직 // ================================================= function loadModelList() { const tableBody = document.querySelector('.model-table tbody'); if (!tableBody) return; fetch('/list-models').then(r=>r.json()).then(models=>{ tableBody.querySelectorAll('tr[data-role]').forEach(row => { const role = row.dataset.role; const d = models[role]; const f = row.querySelector('.model-filename'); const v = row.querySelector('.model-version'); const btn = row.querySelector('.btn-delete'); if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); } else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); } }); }); } const modelTableBody = document.querySelector('.model-table tbody'); if(modelTableBody) { modelTableBody.addEventListener('click', (e) => { if(e.target.classList.contains('btn-delete')) { const row = e.target.closest('tr'); const filename = row.querySelector('.model-filename').textContent; if(filename && filename !== '-') deleteModelFile(filename); } }); } function deleteModelFile(filename) { if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return; fetch('/delete-model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({filename: filename}) }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); } else { alert(`삭제 실패: ${data.message}`); } }); } const globalUploadBtn = document.getElementById('global-upload-button'); const globalFileInput = document.getElementById('global-file-input'); const fileNameDisplay = document.getElementById('file-name-display'); const refreshButton = document.querySelector('.refresh-button'); if (globalFileInput) { globalFileInput.addEventListener('change', () => { fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음'; }); } if (globalUploadBtn) { globalUploadBtn.addEventListener('click', () => { if (!globalFileInput.files.length) return alert('파일을 선택해주세요.'); const formData = new FormData(); formData.append('modelFile', globalFileInput.files[0]); fetch('/upload-model', { method: 'POST', body: formData }) .then(res => res.json()) .then(data => { if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); } else { alert(`실패: ${data.message}`); } }); }); } if (refreshButton) refreshButton.addEventListener('click', loadModelList); // ================================================= // 3. 관심 인물(POI) 관리 로직 // ================================================= const poiTableBody = document.querySelector('#poi-table tbody'); const poiEmptyMsg = document.getElementById('poi-empty-msg'); const btnPoiRegister = document.getElementById('btn-poi-register'); const btnPoiRefresh = document.getElementById('btn-poi-refresh'); // 모달 및 뷰어 요소 const poiModal = document.getElementById('poi-modal'); const poiNameInput = document.getElementById('poi-name-input'); const btnModalConfirm = document.getElementById('btn-modal-confirm'); const btnModalCancel = document.getElementById('btn-modal-cancel'); const imageViewerModal = document.getElementById('image-viewer-modal'); const fullImage = document.getElementById('full-image'); const closeViewerBtn = document.querySelector('.close-viewer'); // POI 목록 로드 function loadPoiList() { if (!poiTableBody) return; fetch('/poi/list') .then(res => res.json()) .then(list => { poiTableBody.innerHTML = ''; if (!list || list.length === 0) { poiEmptyMsg.classList.remove('hidden'); poiTableBody.parentElement.classList.add('hidden'); } else { poiEmptyMsg.classList.add('hidden'); poiTableBody.parentElement.classList.remove('hidden'); list.forEach((item, index) => { const tr = document.createElement('tr'); let previewHtml = '-'; if (item.images && item.images.length > 0) { const imagesHtml = item.images.map(imgName => { const imgSrc = `/poi-images/${item.name}/${imgName}`; return `
`; }).join(''); previewHtml = `
${imagesHtml}
`; } const fileInputId = `poi-file-${item.name}`; tr.innerHTML = ` ${index + 1} ${item.name} ${previewHtml} `; poiTableBody.appendChild(tr); }); } }) .catch(err => console.error('POI Load Error:', err)); } // [전역 함수] 이미지 뷰어 열기 window.openImageViewer = function(src) { if (fullImage && imageViewerModal) { fullImage.src = src; imageViewerModal.classList.remove('hidden'); } }; // 뷰어 닫기 로직 if (closeViewerBtn) { closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); }); } window.addEventListener('click', (e) => { if (e.target === imageViewerModal) { imageViewerModal.classList.add('hidden'); } }); // [전역 함수] 인물 전체 삭제 window.deletePoi = function(name) { if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return; fetch('/poi/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }) .then(res => res.json()) .then(data => { if (data.status === 'success') loadPoiList(); else alert('삭제 실패'); }); }; // [전역 함수] 특정 이미지만 삭제 window.deletePoiImage = function(name, imageName) { if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return; fetch('/poi/delete-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, imageName }) }) .then(res => res.json()) .then(data => { if (data.status === 'success') loadPoiList(); else alert('이미지 삭제 실패'); }); }; // 다중 파일 업로드 처리 window.uploadPoiImage = function(name, input) { if (!input.files || input.files.length === 0) return; const formData = new FormData(); formData.append('poiName', name); for (let i = 0; i < input.files.length; i++) { const file = input.files[i]; const ext = file.name.split('.').pop().toLowerCase(); if (!['jpg', 'jpeg', 'png'].includes(ext)) { alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`); input.value = ''; return; } formData.append('poiFile', file); } fetch('/poi/upload-image', { method: 'POST', body: formData }) .then(res => res.json()) .then(data => { if (data.status === 'success') { alert('이미지 등록 완료'); loadPoiList(); } else { alert('업로드 실패: ' + (data.message || '오류')); } }) .catch(() => alert('오류 발생')); input.value = ''; }; // 이벤트 리스너 if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList); if (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); } if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); } if (btnModalConfirm) { btnModalConfirm.addEventListener('click', () => { const name = poiNameInput.value.trim(); if (!name) return alert('이름을 입력하세요.'); const regex = /^[a-zA-Z0-9]+$/; if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.'); fetch('/poi/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }) .then(res => res.json()) .then(data => { if (data.status === 'success') { poiModal.classList.add('hidden'); loadPoiList(); } else { alert(data.message || '등록 실패'); } }); }); } // ================================================= // 4. 비디오 & 웹소켓 (좌표 그리기 로직 추가됨) // ================================================= const uri = "ws://10.10.11.246:8765"; const canvasEl = document.getElementById("frame"); const statusEl = document.getElementById("status"); 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"); let ctx = null; if (canvasEl) ctx = canvasEl.getContext('2d'); let frameCount = 0; let lastFpsCheckTime = performance.now(); let lastFrameMeta = null; // [신규] 캔버스 영상의 배율(scale)과 여백(offset)을 저장하여 좌표 변환에 사용 let viewConfig = { r: 1, dx: 0, dy: 0 }; function connect() { const ws = new WebSocket(uri); ws.binaryType = "arraybuffer"; ws.onopen = () => { statusEl.textContent = "연결됨"; statusEl.className = "status"; }; ws.onclose = () => { statusEl.textContent = "연결 종료. 재접속..."; statusEl.className = "status err"; setTimeout(connect, 2000); }; ws.onmessage = (event) => { const data = event.data; // 1. 텍스트 데이터(JSON) 처리 if (typeof data === "string") { // console.log("-----" + data); try { const meta = JSON.parse(data); if (meta.type === "frame") { lastFrameMeta = meta; document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`; } else if (meta.type === "det") { // 탐지 정보가 오면 박스를 그림 showDetections(meta); } } catch(e){ console.error(e); } // 2. 바이너리 데이터(영상 프레임) 처리 } else if (data instanceof ArrayBuffer && lastFrameMeta) { frameCount++; const now = performance.now(); if (now - lastFpsCheckTime >= 1000) { fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`; frameCount = 0; lastFpsCheckTime = now; } const blob = new Blob([data], {type: "image/jpeg"}); createImageBitmap(blob).then(bmp => { // 캔버스 크기에 맞춰 영상 비율(레터박스) 계산 canvasEl.width = canvasEl.clientWidth; canvasEl.height = canvasEl.clientHeight; // r: 축소/확대 비율, dx/dy: 중앙 정렬을 위한 여백 const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height); const dw = bmp.width * r; const dh = bmp.height * r; const dx = (canvasEl.width - dw) / 2; const dy = (canvasEl.height - dh) / 2; // 좌표 변환을 위해 현재 뷰 설정 전역 변수에 저장 viewConfig = { r, dx, dy }; ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); ctx.drawImage(bmp, dx, dy, dw, dh); bmp.close(); // 창 크기 변화 시 박스 컨테이너 위치도 동기화 updateBboxContainerPosition(); }); } }; } /** * [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수 */ function showDetections(meta) { // 기존 박스들 초기화 bboxContainerEl.innerHTML = ""; // 메타 정보 표시 준비 const ch = meta.ch !== undefined ? meta.ch : '-'; const seq = meta.seq !== undefined ? meta.seq : '-'; const ts = meta.ts_us !== undefined ? meta.ts_us : '-'; let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`]; const items = meta.items || []; if (items.length === 0) { outputLines.push("No detections"); } else { // 각 탐지 객체에 대해 처리 items.forEach((it, i) => { // 원본 영상 기준 좌표 const x1 = it.x1 || 0; const y1 = it.y1 || 0; const x2 = it.x2 || 0; const y2 = it.y2 || 0; const cls = it.cls !== undefined ? it.cls : '?'; const tag = it.tag !== undefined ? it.tag : ''; // 우측 정보창에 텍스트 로그 추가 outputLines.push(`#${i} | Class:${cls} | Tag:${tag} | Box:[${x1.toFixed(1)}, ${y1.toFixed(1)}, ${x2.toFixed(1)}, ${y2.toFixed(1)}]`); // 좌표 변환: 원본 좌표 -> 캔버스 화면 좌표 // 공식: 화면좌표 = 여백 + (원본좌표 * 배율) const { r, dx, dy } = viewConfig; const screenX = dx + (x1 * r); const screenY = dy + (y1 * r); const screenW = (x2 - x1) * r; const screenH = (y2 - y1) * r; // 박스 DOM 요소 생성 const boxDiv = document.createElement('div'); boxDiv.className = 'bbox'; // style.css에 정의된 붉은 박스 스타일 사용 boxDiv.style.left = `${screenX}px`; boxDiv.style.top = `${screenY}px`; boxDiv.style.width = `${screenW}px`; boxDiv.style.height = `${screenH}px`; // 박스 위 라벨 (선택 사항) const label = document.createElement('div'); label.style.position = 'absolute'; label.style.top = '-16px'; label.style.left = '0'; label.style.backgroundColor = 'red'; label.style.color = 'white'; label.style.fontSize = '10px'; label.style.padding = '1px 3px'; label.style.whiteSpace = 'nowrap'; label.textContent = `C:${cls} T:${tag}`; boxDiv.appendChild(label); bboxContainerEl.appendChild(boxDiv); }); } // 우측 정보창 업데이트 detListEl.textContent = outputLines.join("\n"); } function updateBboxContainerPosition() { if (!canvasEl || !bboxContainerEl) return; // 캔버스의 실제 위치와 크기에 박스 컨테이너를 일치시킴 bboxContainerEl.style.top = canvasEl.offsetTop + 'px'; bboxContainerEl.style.left = canvasEl.offsetLeft + 'px'; bboxContainerEl.style.width = canvasEl.offsetWidth + 'px'; bboxContainerEl.style.height = canvasEl.offsetHeight + 'px'; } // 모델 변경 이벤트 if (modelContainerEl) { modelContainerEl.addEventListener('change', (e) => { if(e.target.name === 'current-model') { updateModelDisplay(); fetch('/set-model', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({model: e.target.value}) }); } }); } function updateModelDisplay() { if(!modelContainerEl || !modelDisplayEl) return; const checked = modelContainerEl.querySelector('input:checked'); if(checked) { const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`); modelDisplayEl.textContent = label ? label.textContent : checked.value; } } // 초기 실행 updateModelDisplay(); if (canvasEl) connect(); window.addEventListener('resize', updateBboxContainerPosition); loadPoiList(); loadModelList(); });