You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

475 lines
21 KiB

7 months ago
// app.js
document.addEventListener('DOMContentLoaded', () => {
// =================================================
// 0. 로그아웃 로직
// =================================================
const logoutBtn = document.getElementById('logout-button');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
if (confirm('정말로 로그아웃 하시겠습니까?')) {
window.location.href = '/';
}
});
}
7 months ago
// =================================================
// 1. 탭 전환 로직
// =================================================
7 months ago
const tabVideo = document.getElementById('tab-video');
const tabModels = document.getElementById('tab-models');
const contentVideo = document.getElementById('content-video');
const contentModels = document.getElementById('content-models');
7 months ago
if (tabVideo && tabModels) {
7 months ago
tabVideo.addEventListener('click', () => {
tabVideo.classList.add('active');
tabModels.classList.remove('active');
contentVideo.classList.add('active');
contentModels.classList.remove('active');
6 months ago
updateBboxContainerPosition();
7 months ago
});
tabModels.addEventListener('click', () => {
tabModels.classList.add('active');
tabVideo.classList.remove('active');
contentModels.classList.add('active');
contentVideo.classList.remove('active');
loadModelList();
7 months ago
loadPoiList();
});
}
7 months ago
// =================================================
6 months ago
// [신규] Mission UI 로직
// =================================================
const MODEL_DEFINITIONS = {
'OBJDET': { name: "객체 탐지", classes: ["person", "car", "van", "truck", "bus", "motor"] },
'FIRE': { name: "화재 감지", classes: ["flame", "smoke"] },
'CROWD': { name: "군중 위험", classes: ["person", "crowd"] },
'FACEATTR': { name: "얼굴 인식", classes: ["face"] },
'ABNORM': { name: "이상 행동", classes: ["fall", "fight"] },
'LPR': { name: "차량 인식", classes: ["plate"] },
'VIPTRACK': { name: "관심 인물", classes: ["person"] }
};
let currentModelCode = 'OBJDET';
let activeClassFilters = new Set();
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', () => {
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
const modelCode = item.dataset.model;
if(modelCode) {
currentModelCode = modelCode;
changeModel(modelCode);
updateFilterBar(modelCode);
}
});
});
function changeModel(modelCode) {
fetch('/set-model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: modelCode})
}).then(r => console.log(`Model changed to ${modelCode}`))
.catch(e => console.error(e));
}
const filterBar = document.getElementById('filter-bar');
function updateFilterBar(modelCode) {
if(!filterBar) return;
filterBar.innerHTML = '';
const def = MODEL_DEFINITIONS[modelCode];
if(!def) return;
const allLabel = document.createElement('label');
allLabel.className = 'filter-check';
const allInput = document.createElement('input');
allInput.type = 'checkbox';
allInput.checked = true; // 기본적으로 All은 체크됨
allLabel.appendChild(allInput);
allLabel.append(' all');
const sep = document.createElement('span');
sep.className = 'v-line';
sep.innerText = '|';
filterBar.appendChild(allLabel);
filterBar.appendChild(sep);
const classInputs = [];
def.classes.forEach(cls => {
const label = document.createElement('label');
label.className = 'filter-check';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true; // All이 체크되어 있으므로 기본적으로 모두 체크
input.dataset.cls = cls;
label.appendChild(input);
label.append(` ${cls}`);
filterBar.appendChild(label);
classInputs.push(input);
// 개별 체크박스 변경 시 로직
input.addEventListener('change', () => {
// 하나라도 꺼지면 All 체크 해제, 모두 켜지면 All 체크
const allChecked = classInputs.every(i => i.checked);
allInput.checked = allChecked;
updateActiveFilters(classInputs);
});
});
// All 체크박스 변경 시 로직
allInput.addEventListener('change', () => {
const isChecked = allInput.checked;
classInputs.forEach(inp => inp.checked = isChecked);
updateActiveFilters(classInputs);
});
// 초기 필터 상태 반영
updateActiveFilters(classInputs);
}
function updateActiveFilters(classInputs) {
activeClassFilters.clear();
// 체크된 항목들만 Set에 추가
classInputs.forEach(inp => {
if(inp.checked) activeClassFilters.add(inp.dataset.cls);
});
}
updateFilterBar('OBJDET');
// =================================================
// 2. 비디오 & 웹소켓 & Summary 패널
// =================================================
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: "plate" } }
};
function getBoxColor(clsName) {
const colorMap = {
'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500',
'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC'
};
return colorMap[clsName] || '#FFFFFF';
}
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const bboxContainerEl = document.getElementById("bbox-container");
const fpsDisplayEl = document.getElementById("fps-display");
const resolutionEl = document.getElementById("resolution-display");
const connMsgEl = document.getElementById("connection-msg");
const summaryListEl = document.getElementById("summary-list");
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 = () => { if(connMsgEl) connMsgEl.textContent = "영상 수신 중"; };
ws.onclose = () => {
if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)";
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;
showDetections(meta);
}
} catch(e){ console.error(e); }
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
if(fpsDisplayEl) 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 => {
if (resolutionEl) {
resolutionEl.textContent = `${bmp.width}x${bmp.height}`;
}
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();
});
}
};
}
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const items = meta.items || [];
const currentCounts = {};
items.forEach((it) => {
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
// [수정] 필터링 로직 강화: Set에 없으면 절대 그리지 않음 (All 체크 로직과 연동)
if (!activeClassFilters.has(displayClassName)) {
return;
}
if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0;
currentCounts[displayClassName]++;
const x1 = it.x1 || 0; const y1 = it.y1 || 0;
const x2 = it.x2 || 0; const y2 = it.y2 || 0;
const boxColor = getBoxColor(displayClassName);
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;
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 = '#000';
label.style.fontSize = '11px';
label.style.fontWeight = 'bold';
label.style.padding = '1px 4px';
label.textContent = displayClassName;
boxDiv.appendChild(label);
bboxContainerEl.appendChild(boxDiv);
});
updateSummaryPanel(currentCounts);
}
function updateSummaryPanel(counts) {
if(!summaryListEl) return;
summaryListEl.innerHTML = '';
const keys = Object.keys(counts);
if (keys.length === 0) {
summaryListEl.innerHTML = '<div style="color:#777; text-align:center; padding:10px;">No detection</div>';
return;
}
keys.forEach(key => {
const row = document.createElement('div');
row.className = 'summary-row';
row.innerHTML = `<span class="label">${key}</span><span class="count">${counts[key]}</span>`;
summaryListEl.appendChild(row);
});
}
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 (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
// =================================================
// 3. 모델 파일 관리 로직
7 months ago
// =================================================
function loadModelList() {
const tableBody = document.querySelector('.model-table tbody');
7 months ago
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'); }
});
7 months ago
});
}
7 months ago
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) {
7 months ago
if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
fetch('/delete-model', {
method: 'POST',
7 months ago
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}`); }
});
7 months ago
}
7 months ago
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');
7 months ago
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);
// =================================================
6 months ago
// 4. 관심 인물(POI) 관리 로직
7 months ago
// =================================================
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');
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 {
7 months ago
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}`;
6 months ago
return `<div class="preview-item"><img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')"><button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button></div>`;
7 months ago
}).join('');
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
}
const fileInputId = `poi-file-${item.name}`;
6 months ago
tr.innerHTML = `<td>${index + 1}</td><td><span style="font-weight:bold; margin-right:10px;">${item.name}</span><button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button></td><td>${previewHtml}</td><td><input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)"><label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333;">찾아보기</label></td>`;
7 months ago
poiTableBody.appendChild(tr);
});
}
7 months ago
})
.catch(err => console.error('POI Load Error:', err));
}
7 months ago
7 months ago
window.openImageViewer = function(src) {
if (fullImage && imageViewerModal) {
fullImage.src = src;
imageViewerModal.classList.remove('hidden');
}
};
if (closeViewerBtn) {
6 months ago
closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); });
7 months ago
}
window.addEventListener('click', (e) => {
6 months ago
if (e.target === imageViewerModal) { imageViewerModal.classList.add('hidden'); }
7 months ago
});
window.deletePoi = function(name) {
6 months ago
if (!confirm(`관심인물 '${name}' 삭제?`)) 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(); });
7 months ago
};
window.deletePoiImage = function(name, imageName) {
6 months ago
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(); });
7 months ago
};
window.uploadPoiImage = function(name, input) {
if (!input.files || input.files.length === 0) return;
const formData = new FormData();
formData.append('poiName', name);
6 months ago
for (let i = 0; i < input.files.length; i++) { formData.append('poiFile', input.files[i]); }
fetch('/poi/upload-image', { method: 'POST', body: formData }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('완료'); loadPoiList(); } else { alert('실패'); } });
7 months ago
input.value = '';
};
if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
6 months ago
if (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); }
if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); }
7 months ago
if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim();
6 months ago
if (!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('실패'); } });
7 months ago
});
}
loadModelList();
});