diff --git a/public/app.js b/public/app.js index 273e8b0..636dbe7 100644 --- a/public/app.js +++ b/public/app.js @@ -1,608 +1,408 @@ // app.js -// DOM(HTML)이 모두 로드되었을 때 실행 document.addEventListener('DOMContentLoaded', () => { - // 탭 버튼과 콘텐츠 요소를 가져옵니다. + // ================================================= + // 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) { // 탭 요소가 있는지 확인 + 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(); + loadPoiList(); }); } - // ========== AI 모델 목록 로드 함수 ========== + // ================================================= + // 2. 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('모델 목록을 불러오는 데 실패했습니다.'); + 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'); } }); + }); } - // ========== AI 모델 목록 로드 함수 끝 ========== - + 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) { - console.log(`Requesting deletion of: ${filename}`); - - fetch('/delete-model', { // server.js에 추가할 엔드포인트 + if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return; + fetch('/delete-model', { 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('삭제 중 오류가 발생했습니다.'); - }); + 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}`); } + }); } - // ========== 모델 삭제 요청 함수 끝 ========== - - - // ========== 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 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'); - // 새로고침 버튼 요소 - 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; - } + 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 { - fileNameDisplay.textContent = '선택된 파일 없음'; - } - }); - } + 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}
`; + } - // '업로드' 버튼 클릭 시 - if (globalUploadButton && globalFileInput) { - globalUploadButton.addEventListener('click', () => { - // 파일이 선택되었는지 확인 - if (globalFileInput.files.length === 0) { - alert('먼저 .aiwbin 파일을 선택해주세요.'); - return; + const fileInputId = `poi-file-${item.name}`; + + // [수정됨] multiple 속성 추가 + tr.innerHTML = ` + ${index + 1} + + ${item.name} + + + ${previewHtml} + + + + + `; + poiTableBody.appendChild(tr); + }); } + }) + .catch(err => console.error('POI Load Error:', err)); + } - const file = globalFileInput.files[0]; + // [전역 함수] 이미지 뷰어 열기 + window.openImageViewer = function(src) { + if (fullImage && imageViewerModal) { + fullImage.src = src; + imageViewerModal.classList.remove('hidden'); + } + }; - // (이중 확인) 파일 확장자 확인 - if (!file.name.endsWith('.aiwbin')) { - alert('잘못된 파일 형식입니다. .aiwbin 파일만 업로드할 수 있습니다.'); - globalFileInput.value = ''; - fileNameDisplay.textContent = '선택된 파일 없음'; - return; - } + // 뷰어 닫기 로직 + if (closeViewerBtn) { + closeViewerBtn.addEventListener('click', () => { + imageViewerModal.classList.add('hidden'); + }); + } + window.addEventListener('click', (e) => { + if (e.target === imageViewerModal) { + imageViewerModal.classList.add('hidden'); + } + }); - // FormData 객체 생성 - const formData = new FormData(); - formData.append('modelFile', file); // 'modelFile'은 server.js와 일치해야 함 - // 업로드 중 버튼 비활성화 - globalUploadButton.disabled = true; - globalUploadButton.textContent = '업로드 중...'; + // [전역 함수] 인물 전체 삭제 + window.deletePoi = function(name) { + if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return; - // 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 = '업로드'; - }); - }); - } + 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('삭제 실패'); + }); + }; - // '새로고침' 버튼 클릭 시 - if (refreshButton) { - refreshButton.addEventListener('click', () => { - loadModelList(); // 목록 새로고침 함수 호출 - }); - } + // [전역 함수] 특정 이미지만 삭제 + window.deletePoiImage = function(name, imageName) { + if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return; - // ========== [추가됨] 삭제 버튼 이벤트 리스너 (이벤트 위임) ========== - if (tableBody) { - tableBody.addEventListener('click', (event) => { - // 클릭된 요소가 'btn-delete' 클래스를 가지고 있는지 확인 - if (event.target.classList.contains('btn-delete')) { + 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('이미지 삭제 실패'); + }); + }; - // 클릭된 버튼에서 가장 가까운 (행)을 찾습니다. - const row = event.target.closest('tr'); - if (!row) return; + // [수정됨] 다중 파일 업로드 처리 + window.uploadPoiImage = function(name, input) { + if (!input.files || input.files.length === 0) return; - // 행에서 파일명 셀(.model-filename)을 찾아 파일명을 가져옵니다. - const fileCell = row.querySelector('.model-filename'); - const filename = fileCell ? fileCell.textContent : null; + const formData = new FormData(); + formData.append('poiName', name); - // 파일명이 없거나 '-' 이면 (파일이 할당되지 않음) 중단 - if (!filename || filename === '-') { - alert('삭제할 파일이 없습니다.'); - return; - } + // 선택된 모든 파일을 formData에 추가 + for (let i = 0; i < input.files.length; i++) { + const file = input.files[i]; + const ext = file.name.split('.').pop().toLowerCase(); - // 행의 두 번째 셀()에서 역할 텍스트를 가져옵니다. - const roleText = row.cells[1] ? row.cells[1].textContent : '알 수 없는 역할'; + if (!['jpg', 'jpeg', 'png'].includes(ext)) { + alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`); + input.value = ''; + return; + } - // 사용자에게 삭제 확인을 받습니다. - if (confirm(`정말로 이 모델 파일을 삭제하시겠습니까?\n\n역할: ${roleText}\n파일: ${filename}`)) { - // 확인 시, 삭제 함수 호출 - deleteModelFile(filename); - } - } - }); + formData.append('poiFile', file); } - // ========== 삭제 버튼 이벤트 리스너 끝 ========== - } - // ========== AI 모델 업로드/관리 로직 끝 ========== + 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 = ''; + }; - // ========== 로그아웃 버튼 처리 ========== - const logoutButton = document.getElementById('logout-button'); - if (logoutButton) { // 로그아웃 버튼이 있는지 확인 - logoutButton.addEventListener('click', () => { - console.log('Logout clicked'); + // 이벤트 리스너 + if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList); - // [수정됨] 사용자에게 확인을 받습니다. - if (confirm('정말로 로그아웃하시겠습니까?')) { - // 확인을 누르면 로그인 페이지(루트 '/')로 이동 - window.location.href = '/'; - } - // 취소를 누르면 아무 일도 일어나지 않습니다. + if (btnPoiRegister) { + btnPoiRegister.addEventListener('click', () => { + poiNameInput.value = ''; + poiModal.classList.remove('hidden'); }); } - // ========== 로그아웃 버튼 처리 끝 ========== + if (btnModalCancel) { + btnModalCancel.addEventListener('click', () => { + poiModal.classList.add('hidden'); + }); + } - // ========== dashboard.html 스크립트 추가 시작 ========== - const uri = "ws://10.10.11.246:8765"; // 필요하면 여기만 수정 + 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 || '등록 실패'); + } + }); + }); + } - // [수정] imgEl -> canvasEl로 변경 + // ================================================= + // 4. 비디오 & 웹소켓 (기존 기능) + // ================================================= + const uri = "ws://10.10.11.246:8765"; const canvasEl = 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"); - - // [추가] 캔버스 2D 컨텍스트 let ctx = null; - if (canvasEl) { - ctx = canvasEl.getContext('2d'); - } - // [추가] 클래스별 색상 팔레트 (원하는 색상으로 수정 가능) - 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 계산용 변수 + if (canvasEl) ctx = canvasEl.getContext('2d'); let frameCount = 0; let lastFpsCheckTime = performance.now(); - let lastFrameMeta = null; - // [수정] canvasEl 및 ctx 존재 여부 확인 - if (canvasEl && ctx && statusEl && frameInfoEl && detListEl && bboxContainerEl && modelContainerEl) { + 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; + if (typeof data === "string") { + 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){} + } 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; + 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; + ctx.clearRect(0,0,canvasEl.width, canvasEl.height); + ctx.drawImage(bmp, dx, dy, dw, dh); + bmp.close(); + updateBboxContainerPosition(); + }); + } + }; + } - // 모델 변경 이벤트 리스너 - modelContainerEl.addEventListener('change', (event) => { - if (event.target && event.target.name === 'current-model') { - // [추가] 모델 이름 즉시 업데이트 - updateModelDisplay(); + function showDetections(meta) { + bboxContainerEl.innerHTML = ""; + const items = meta.items || []; + let lines = []; + items.forEach((it, i) => { + lines.push(`#${i} cls=${it.cls} score=${it.conf || 0}`); + }); + detListEl.textContent = lines.join("\n"); + } - const selectedModel = event.target.value; - console.log(`Model changed to: ${selectedModel}`); + 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: 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() { - // [수정] imgEl -> canvasEl - if (!canvasEl || !bboxContainerEl) return; - const top = canvasEl.offsetTop; - const left = canvasEl.offsetLeft; - const width = canvasEl.offsetWidth; - const height = canvasEl.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 = ""; - - // [수정] imgEl -> canvasEl - if (!canvasEl || !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(); - // [수정] imgEl -> canvasEl - const imgWidth = canvasEl.clientWidth; - const imgHeight = canvasEl.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)}` - ); + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({model: e.target.value}) }); - 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; - } - - // [수정] ArrayBuffer 처리: img.src 대신 createImageBitmap 및 canvas.drawImage 사용 - if (data instanceof ArrayBuffer && lastFrameMeta) { - - // ========== [수정됨] FPS 계산 로직 (이곳으로 이동) ========== - // WebSocket에서 ArrayBuffer(프레임)를 수신한 시점을 기준으로 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; - } - // ========== FPS 계산 로직 끝 ========== - - - const blob = new Blob([data], {type: "image/jpeg"}); - - // createImageBitmap으로 비동기 디코딩 및 렌더링 - createImageBitmap(blob) - .then(imageBitmap => { - // 캔버스 크기를 CSS에 정의된 표시 크기로 맞춥니다. - // (object-fit: contain을 시뮬레이션하기 위해 매번 필요) - canvasEl.width = canvasEl.clientWidth; - canvasEl.height = canvasEl.clientHeight; - - const canvasWidth = canvasEl.width; - const canvasHeight = canvasEl.height; - const frameWidth = imageBitmap.width; // 실제 이미지 비트맵의 너비 - const frameHeight = imageBitmap.height; // 실제 이미지 비트맵의 높이 - - // 'object-fit: contain' 로직 - const widthRatio = canvasWidth / frameWidth; - const heightRatio = canvasHeight / frameHeight; - const ratio = Math.min(widthRatio, heightRatio); - - const videoDisplayWidth = frameWidth * ratio; - const videoDisplayHeight = frameHeight * ratio; - const offsetX = (canvasWidth - videoDisplayWidth) / 2; - const offsetY = (canvasHeight - videoDisplayHeight) / 2; - - // 캔버스를 지웁니다 (CSS의 background-color가 비친다) - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - - // 계산된 위치에 이미지를 그립니다. - ctx.drawImage(imageBitmap, offsetX, offsetY, videoDisplayWidth, videoDisplayHeight); - - // 비트맵 메모리 해제 - imageBitmap.close(); - - // 프레임이 그려진 *이후*에 바운딩 박스 컨테이너 위치 업데이트 - updateBboxContainerPosition(); - - // [수정됨] FPS 계산 로직이 위로 이동했으므로 여기서는 제거됩니다. - - }) - .catch(e => { - console.error("createImageBitmap error:", e); - }); - } - }; + 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(); - - // WebSocket 연결 시작 - connect(); - - // 창 크기 변경 시 이벤트 - window.addEventListener('resize', updateBboxContainerPosition); - - } // if (요소 확인) 끝 - // ========== video-test.html 스크립트 추가 끝 ========== - - - // ========== 페이지 로드 시 모델 목록 즉시 로드 ========== + updateModelDisplay(); + if (canvasEl) connect(); + window.addEventListener('resize', updateBboxContainerPosition); + loadPoiList(); loadModelList(); }); \ No newline at end of file diff --git a/public/dashboard.html b/public/dashboard.html index 3e7f236..9b6da20 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -20,20 +20,15 @@
-
-
-
연결 시도 중...
- - + -
@@ -65,13 +60,12 @@
-
- + 선택된 파일 없음 @@ -82,62 +76,77 @@ 번호 - Al Model 역할 + AI Model 역할 파일명 현재 버전 삭제 + - 1 - 객체 탐지/ 분류 - - - v1.0 - - - + 1객체 탐지/ 분류-v1.0 - 2 - 화재(불꽃, 연기) 감지 - - - v1.0 - - - + 2화재(불꽃, 연기) 감지-v1.0 - 3 - 이상행동(쓰러짐, 폭행) 감지 - - - v1.0 - - - + 3이상행동(쓰러짐, 폭행) 감지-v1.0 - - 4 - 얼굴/ 인상착의 인식 - - - - - - - + + 4얼굴/ 인상착의 인식-- - 5 - 차량 번호판/ 차종 인식 - - - - - - - + 5차량 번호판/ 차종 인식-- + +
+ +
+ +
+ + +
+
+ + + + + + + + + + + + +
번호관심인물명미리 보기파일 등록
+
+ + + + \ No newline at end of file diff --git a/public/style.css b/public/style.css index 6b59498..2ecdc89 100644 --- a/public/style.css +++ b/public/style.css @@ -1,104 +1,72 @@ +/* style.css */ + /* 기본 및 공통 스타일 */ body { font-family: Arial, sans-serif; margin: 0; padding: 0; - background-color: #f0f2f5; /* 기본 배경색 (로그인 페이지 등에서 사용) */ - color: #333; /* 기본 글자색 (로그인 페이지 등에서 사용) */ - /* [수정] flexbox 레이아웃 적용 */ + background-color: #f0f2f5; + color: #333; display: flex; flex-direction: column; - height: 100vh; /* 전체 뷰포트 높이 사용 */ -} - -/* [추가] 로그인 페이지 좌측 상단 타이틀 */ -.page-title { - position: absolute; /* 페이지 기준 절대 위치 */ - top: 2rem; /* 위쪽 여백 */ - left: 4rem; /* 왼쪽 여백 */ - font-size: 2.5rem; /* 폰트 크기 */ - color: #f0f0f0; /* 요청대로 밝은 색상 (흰색에 가까움) */ - font-weight: 600; - /* 배경 이미지와 구분을 위한 텍스트 그림자 */ - text-shadow: 0 2px 5px rgba(0, 0, 0, 0.6); - z-index: 10; /* 다른 요소들 위에 표시 */ + height: 100vh; } - -/* ========== [Dark Theme 수정] ========== */ +/* 버튼 기본 스타일 reset 및 공통 */ button { cursor: pointer; border-radius: 4px; - /* [수정] 다크 테마 기본 버튼 스타일 */ border: 1px solid #555; background-color: #3a3a3a; color: #e0e0e0; padding: 5px 10px; } -/* ====================================== */ - -/* 1. 로그인 페이지 스타일 (index.html) */ -/* ... (기존 로그인 스타일 동일 - 다크 테마의 영향을 받지 않도록 고유 스타일 유지) ... */ +/* 로그인 페이지 등 배경 스타일 */ .login-page { display: flex; justify-content: center; align-items: center; height: 100vh; - - /* ========== [수정됨] 배경 이미지 적용 ========== */ - background-image: url('drone_background.png'); /* 배경 이미지 파일 경로 */ - background-size: cover; /* 화면을 꽉 채우도록 (비율 유지) */ - background-position: center; /* 이미지 중앙 정렬 */ - background-repeat: no-repeat; /* 이미지 반복 안 함 */ - /* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); <-- 기존 배경 제거 */ - /* ============================================ */ + background: #f0f2f5; } - .login-container { - /* [수정] 배경색을 body 기본 배경색과 유사하게 변경 */ - background: #f0f2f5; + background: #ffffff; padding: 2rem 3rem; border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - text-align: center; - /* [추가] 너비를 기존보다 2배가량 넓게 설정 (예: 700px) */ - width: 700px; - /* [추가] width 사용 시 padding이 너비에 포함되도록 설정 */ + width: 400px; box-sizing: border-box; } - .login-container h1 { - font-size: 1.5rem; - color: #444; /* body의 color(#e0e0e0)를 상속받지 않도록 명시 */ - margin-bottom: 2rem; + font-size: 2.2rem; + color: #333; + margin-bottom: 0.5rem; + font-weight: bold; +} +.login-subtext { + font-size: 1rem; + color: #888; + margin: 0 0 2.5rem 0; } - .input-group { margin-bottom: 1rem; + position: relative; text-align: left; } - -.input-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: bold; - color: #333; /* body의 color(#e0e0e0)를 상속받지 않도록 명시 */ -} - .input-group input { width: 100%; - padding: 0.8rem; + padding: 1rem 1rem 1rem 3rem; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; - background-color: #fff; /* 다크 테마 상속 방지 */ - color: #333; /* 다크 테마 상속 방지 */ + background-color: #fff; + color: #333; + font-size: 1rem; } - .login-button { - width: 100%; - padding: 0.8rem; + width: auto; + padding: 0.8rem 2.5rem; background-color: #5a67d8; color: white; border: none; @@ -106,488 +74,241 @@ button { font-weight: bold; margin-top: 1rem; } -.login-button:hover { - background-color: #434190; +.login-button:hover { background-color: #434190; } +.footer-bar { + position: absolute; bottom: 0; left: 0; width: 100%; height: 50px; + background-color: #ffffff; opacity: 0.7; z-index: 100; + display: flex; justify-content: center; align-items: center; gap: 20px; } -/* 2. 대시보드 페이지 스타일 (dashboard.html) */ +/* 대시보드 헤더 */ header { - background-color: #333; /* 이미 어두움 */ + background-color: #333; padding: 0 1rem; - /* [수정] 크기 고정 */ flex-shrink: 0; } - -.tabs { - display: flex; - justify-content: space-between; - align-items: center; -} - +.tabs { display: flex; justify-content: space-between; align-items: center; } .tab-button { - background: none; - border: none; - color: #aaa; /* 이미 밝음 */ - padding: 1rem 1.5rem; - font-size: 1rem; - font-weight: bold; -} - -.tab-button.active { - color: white; - border-bottom: 3px solid #5a67d8; + background: none; border: none; color: #aaa; + padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold; } - +.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; } .logout-button { - background-color: #d9534f; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - font-size: 0.9rem; - font-weight: bold; - cursor: pointer; -} -.logout-button:hover { - background-color: #c9302c; + background-color: #d9534f; color: white; border: none; + padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold; } -/* ========== [Dark Theme 수정] ========== */ +/* 메인 컨텐츠 (다크 테마) */ main { padding: 1rem; - /* [수정] 남은 공간을 모두 채움 */ flex-grow: 1; display: flex; flex-direction: column; - min-height: 0; /* 내용이 많을 때 축소 가능하도록 */ - - /* [추가] 다크 테마 배경 및 글자색 */ + min-height: 0; background-color: #1a1a1a; color: #e0e0e0; } -/* ====================================== */ -.tab-content { - display: none; -} +.tab-content { display: none; } .tab-content.active { - /* [수정] display: block -> flex */ - display: flex; - flex-direction: column; - /* [수정] 부모(main) 공간을 채움 */ - flex-grow: 1; - min-height: 0; + display: flex; flex-direction: column; flex-grow: 1; min-height: 0; } -/* 2-1. Video 탭 (PDF 2페이지) */ -/* (이 부분은 대부분 이미 어두운 테마 스타일을 가지고 있음) */ - -/* [추가] .video-container가 공간을 채우도록 */ -.video-container { - display: flex; - flex-direction: column; - flex-grow: 1; - min-height: 0; -} - -/* ========== .video-player 스타일 수정 ========== */ +/* Video 스타일 */ +.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; } .video-player { - width: 100%; - /* [제거] min-height: 720px; */ - /* [추가] .video-container 공간을 채움 */ - flex-grow: 1; - min-height: 0; /* 축소 가능하도록 */ - background-color: #111; /* video-test.html 배경색 적용 (이미 어두움) */ - color: #eee; /* video-test.html 글자색 적용 (이미 밝음) */ - display: flex; - flex-direction: row; /* 가로 정렬로 변경 */ - gap: 10px; /* video-test.html gap 적용 */ - padding: 10px; /* video-test.html padding 적용 */ - box-sizing: border-box;/* video-test.html box-sizing 적용 */ - border-radius: 4px; - position: relative; -} - -/* ========== [수정됨] #current-model