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.
504 lines
22 KiB
504 lines
22 KiB
// 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');
|
|
updateBboxContainerPosition();
|
|
});
|
|
|
|
tabModels.addEventListener('click', () => {
|
|
tabModels.classList.add('active');
|
|
tabVideo.classList.remove('active');
|
|
contentModels.classList.add('active');
|
|
contentVideo.classList.remove('active');
|
|
loadModelList();
|
|
loadPoiList();
|
|
});
|
|
}
|
|
|
|
// =================================================
|
|
// [신규] 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;
|
|
|
|
// 1. 기본 'All' 체크박스 생성
|
|
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 = [];
|
|
|
|
// 2. 모델별 클래스 필터 생성
|
|
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', () => {
|
|
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);
|
|
});
|
|
|
|
// 3. [신규 기능] LPR(차량 인식) 모드일 때만 'Zoom In' 체크박스 추가
|
|
if (modelCode === 'LPR') {
|
|
const zoomLabel = document.createElement('label');
|
|
zoomLabel.className = 'filter-check filter-push-right'; // 우측 정렬 클래스 적용
|
|
|
|
const zoomInput = document.createElement('input');
|
|
zoomInput.type = 'checkbox';
|
|
zoomInput.id = 'zoom-toggle';
|
|
zoomInput.checked = false; // 기본값: 선택 안 됨
|
|
|
|
// 줌 기능 이벤트 리스너 (필요 시 로직 구현)
|
|
zoomInput.addEventListener('change', (e) => {
|
|
if(e.target.checked) {
|
|
console.log("Zoom In Activated");
|
|
// 여기에 줌 확대 로직 추가
|
|
} else {
|
|
console.log("Zoom In Deactivated");
|
|
// 여기에 줌 해제 로직 추가
|
|
}
|
|
});
|
|
|
|
zoomLabel.appendChild(zoomInput);
|
|
zoomLabel.append(' Zoom In');
|
|
filterBar.appendChild(zoomLabel);
|
|
}
|
|
|
|
// 초기 필터 상태 반영
|
|
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. 모델 파일 관리 로직
|
|
// =================================================
|
|
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);
|
|
|
|
|
|
// =================================================
|
|
// 4. 관심 인물(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');
|
|
|
|
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 `<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>`;
|
|
}).join('');
|
|
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
|
|
}
|
|
const fileInputId = `poi-file-${item.name}`;
|
|
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>`;
|
|
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}' 삭제?`)) 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(); });
|
|
};
|
|
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(); });
|
|
};
|
|
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++) { 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('실패'); } });
|
|
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('이름 입력');
|
|
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('실패'); } });
|
|
});
|
|
}
|
|
|
|
loadModelList();
|
|
});
|
|
|
|
//2025-12-04 15:41
|