// 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. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
// =================================================
// [설정] Tag/Class 매핑 테이블
const LABEL_MAP = {
1: {
tagName: "객체 탐지/ 분류",
classes: {
0: "person",
1: "car",
2: "van",
3: "truck",
4: "bus",
5: "motor"
}
},
2: {
tagName: "화재 인식",
classes: {
0: "flame",
1: "smoke"
}
},
3: {
tagName: "얼굴 인식",
classes: {
0: "face"
}
},
4: {
tagName: "차량번호판 및 차종 인식",
classes: {
0: "License plate"
}
}
};
// [설정] 박스 색상 생성 함수
function getBoxColor(tag, cls) {
// Tag 2: 화재 관련 (긴급한 색상)
if (tag === 2) {
if (cls === 0) return '#FF0000'; // Flame: 빨강
if (cls === 1) return '#808080'; // Smoke: 회색
}
// Tag 1: 객체 탐지 (다양한 색상)
if (tag === 1) {
const colors = [
'#00FF00', // Person: 라임 그린
'#00FFFF', // Car: 시안
'#FFA500', // Van: 오렌지
'#FF69B4', // Truck: 핫핑크
'#9370DB', // Bus: 미디엄 퍼플
'#FFD700' // Motor: 골드
];
return colors[cls % colors.length] || '#FFFFFF';
}
// Tag 3: 얼굴
if (tag === 3) return '#1E90FF'; // 도저 블루
// Tag 4: 번호판
if (tag === 4) return '#32CD32'; // 라임 그린
// 기타 기본값
return '#FF0000';
}
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;
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}`;
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;
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를 그리는 함수
* - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
* - Class별 색상 구분 적용
*/
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;
// 데이터에서 tag와 cls 추출
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
const tId = it.tid !== undefined ? it.tid : -1;
// 1. 이름 매핑 Lookup
let displayTagName = `Tag ${tagId}`;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId]) {
displayTagName = LABEL_MAP[tagId].tagName;
if (LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
}
// 2. 색상 결정
const boxColor = getBoxColor(tagId, clsId);
// 로그창 출력 (원본 데이터 유지)
outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
// 좌표 변환
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';
// [스타일 적용] 동적 색상 및 위치
boxDiv.style.left = `${screenX}px`;
boxDiv.style.top = `${screenY}px`;
boxDiv.style.width = `${screenW}px`;
boxDiv.style.height = `${screenH}px`;
boxDiv.style.borderColor = boxColor; // 테두리 색상 변경
// 라벨 생성
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px'; // 박스 바로 위
label.style.left = '-2px'; // 테두리 두께 고려 정렬
label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
label.style.color = 'white'; // 글자는 흰색
label.style.fontSize = '12px';
label.style.fontWeight = 'bold';
label.style.padding = '2px 6px';
label.style.borderRadius = '3px';
label.style.whiteSpace = 'nowrap';
label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
// [요청 포맷 적용] Class 명 : Tag id
label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
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;
}
}
const detPanelToggle = document.getElementById('det-panel-toggle');
const detWrap = document.getElementById('det-wrap');
if (detPanelToggle && detWrap) {
detPanelToggle.addEventListener('change', (e) => {
if (e.target.checked) {
// 체크됨 -> 패널 보이기 (기존 flex 속성 복구)
detWrap.classList.remove('collapsed-panel');
} else {
// 체크 해제 -> 패널 숨기기
detWrap.classList.add('collapsed-panel');
}
// 패널 크기가 변하므로 캔버스 내 BBox 위치 재계산 필요
// 약간의 지연 후 실행 (Flexbox 애니메이션 등이 없으므로 즉시 해도 되지만 안전하게)
setTimeout(() => {
if(typeof updateBboxContainerPosition === 'function') {
updateBboxContainerPosition();
}
}, 50);
});
}
// 초기 실행
updateModelDisplay();
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
loadPoiList();
loadModelList();
});