parent
267cfd805c
commit
2c4cc417cc
@ -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 `
|
||||
<div class="preview-item">
|
||||
<img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')" title="클릭하여 확대">
|
||||
<button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
|
||||
}
|
||||
|
||||
// '업로드' 버튼 클릭 시
|
||||
if (globalUploadButton && globalFileInput) {
|
||||
globalUploadButton.addEventListener('click', () => {
|
||||
// 파일이 선택되었는지 확인
|
||||
if (globalFileInput.files.length === 0) {
|
||||
alert('먼저 .aiwbin 파일을 선택해주세요.');
|
||||
return;
|
||||
const fileInputId = `poi-file-${item.name}`;
|
||||
|
||||
// [수정됨] multiple 속성 추가
|
||||
tr.innerHTML = `
|
||||
<td>${index + 1}</td>
|
||||
<td>
|
||||
<span style="font-weight:bold; font-size:1.1rem; 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; border:1px solid #ccc;">찾아보기</label>
|
||||
</td>
|
||||
`;
|
||||
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('이미지 삭제 실패');
|
||||
});
|
||||
};
|
||||
|
||||
// 클릭된 버튼에서 가장 가까운 <tr>(행)을 찾습니다.
|
||||
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();
|
||||
|
||||
// 행의 두 번째 셀(<td>)에서 역할 텍스트를 가져옵니다.
|
||||
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();
|
||||
});
|
||||
@ -1,249 +1,306 @@
|
||||
// server.js
|
||||
|
||||
// 1. 모듈 불러오기
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const multer = require('multer'); // 파일 업로드 처리를 위한 multer
|
||||
const fs = require('fs'); // 파일 시스템 접근을 위한 fs
|
||||
const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
|
||||
// 2. Express 앱 생성
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
// 업로드 경로 설정
|
||||
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
|
||||
// ========================================================
|
||||
// [설정] 경로 및 폴더 초기화
|
||||
// ========================================================
|
||||
|
||||
// 업로드 경로가 없으면 생성
|
||||
// AI 모델용 업로드 경로
|
||||
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
|
||||
fs.mkdirSync(uploadPath, { recursive: true });
|
||||
|
||||
// Multer 저장소 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadPath);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, file.originalname);
|
||||
}
|
||||
// 관심 인물(POI) 폴더 경로 (vip_tracks)
|
||||
const vipTracksPath = '/mnt/user_data/applications/misc/vip_tracks';
|
||||
fs.mkdirSync(vipTracksPath, { recursive: true });
|
||||
|
||||
|
||||
// ========================================================
|
||||
// [설정] Multer (파일 업로드 핸들러)
|
||||
// ========================================================
|
||||
|
||||
// 1) AI 모델용 스토리지
|
||||
const modelStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => { cb(null, uploadPath); },
|
||||
filename: (req, file, cb) => { cb(null, file.originalname); }
|
||||
});
|
||||
|
||||
// Multer 파일 필터 설정 (.aiwbin 확장자만 허용)
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const modelFileFilter = (req, file, cb) => {
|
||||
if (!file.originalname.endsWith('.aiwbin')) {
|
||||
// 허용되지 않는 파일 형식
|
||||
return cb(new Error('Invalid file type: Only .aiwbin files are allowed'), false);
|
||||
}
|
||||
// 허용되는 파일 형식
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Multer 업로드 인스턴스 생성
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
fileFilter: fileFilter
|
||||
const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter });
|
||||
|
||||
|
||||
// 2) 관심 인물(POI) 이미지용 스토리지
|
||||
const poiStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => { cb(null, vipTracksPath); },
|
||||
filename: (req, file, cb) => {
|
||||
// 임시 저장을 위해 충돌 없는 이름을 생성
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const poiFileFilter = (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (!['.jpg', '.jpeg', '.png'].includes(ext)) {
|
||||
return cb(new Error('이미지 파일만 업로드 가능합니다 (.jpg, .png)'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// 3. 'public' 폴더의 파일들을 정적 파일로 제공하도록 설정합니다.
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter });
|
||||
|
||||
// POST 요청의 JSON 본문(body)을 파싱하기 위한 미들웨어
|
||||
|
||||
// ========================================================
|
||||
// [미들웨어 및 라우트 설정]
|
||||
// ========================================================
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.json());
|
||||
|
||||
// 4. 루트 경로('/')로 접속하면 'index.html'(로그인 페이지)를 보냅니다.
|
||||
// POI 이미지 서빙
|
||||
app.get('/poi-images/:folder/:filename', (req, res) => {
|
||||
const { folder, filename } = req.params;
|
||||
|
||||
if (folder.includes('..') || filename.includes('..')) {
|
||||
return res.status(400).send('Invalid path');
|
||||
}
|
||||
|
||||
const filePath = path.join(vipTracksPath, folder, filename);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
res.sendFile(filePath);
|
||||
} else {
|
||||
res.status(404).send('Image not found');
|
||||
}
|
||||
});
|
||||
|
||||
// 페이지 라우팅
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// 5. '/dashboard' 경로로 접속하면 'dashboard.html'(메인 페이지)를 보냅니다.
|
||||
app.get('/dashboard', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
||||
});
|
||||
|
||||
// 6. 모델 변경 API 엔드포인트 (*** 수정된 부분 ***)
|
||||
app.post('/set-model', (req, res) => {
|
||||
const { model } = req.body; // app.js에서 보낸 { model: "OBJDET" } 등
|
||||
|
||||
if (!model) {
|
||||
return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' });
|
||||
}
|
||||
// ========================================================
|
||||
// [API] AI 모델 관련
|
||||
// ========================================================
|
||||
|
||||
console.log(`[서버] 모델 변경 명령 수신: ${model}`);
|
||||
app.post('/set-model', (req, res) => {
|
||||
const { model } = req.body;
|
||||
if (!model) return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' });
|
||||
|
||||
// --- 요청하신 쉘 스크립트 실행으로 변경 ---
|
||||
const scriptCommand = '/mnt/user_data/feat_control/feat_on.sh';
|
||||
const args = [model]; // OBJDET, ABNORM, CROWD 등
|
||||
|
||||
console.log(`[서버] 실행: ${scriptCommand} ${args.join(' ')}`);
|
||||
|
||||
// 스크립트 파일에 실행 권한(chmod +x)이 있다고 가정합니다.
|
||||
const args = [model];
|
||||
const scriptProcess = spawn(scriptCommand, args);
|
||||
|
||||
let stdoutData = '';
|
||||
let stderrData = '';
|
||||
|
||||
scriptProcess.stdout.on('data', (data) => {
|
||||
console.log(`Script stdout: ${data}`);
|
||||
stdoutData += data.toString();
|
||||
});
|
||||
|
||||
scriptProcess.stderr.on('data', (data) => {
|
||||
console.error(`Script stderr: ${data}`);
|
||||
stderrData += data.toString();
|
||||
});
|
||||
scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); });
|
||||
|
||||
scriptProcess.on('close', (code) => {
|
||||
console.log(`Script process exited with code ${code}`);
|
||||
if (code === 0) {
|
||||
res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData });
|
||||
} else {
|
||||
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패', error: stderrData });
|
||||
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
scriptProcess.on('error', (err) => {
|
||||
console.error('[서버] 쉘 프로세스 시작 실패:', err);
|
||||
// "ENOENT" 오류는
|
||||
// 1) 스크립트 경로가 잘못되었거나,
|
||||
// 2) 스크립트 파일에 실행 권한이 없을 때 자주 발생합니다.
|
||||
res.status(500).json({ status: 'error', message: '프로세스 시작 실패', error: err.message });
|
||||
});
|
||||
// --- 스크립트 실행 끝 ---
|
||||
});
|
||||
|
||||
// 7. 모델 파일 업로드 엔드포인트
|
||||
app.post('/upload-model', (req, res) => {
|
||||
|
||||
upload.single('modelFile')(req, res, function (err) {
|
||||
|
||||
if (err instanceof multer.MulterError) {
|
||||
console.error('[Multer Error]:', err.message);
|
||||
return res.status(500).json({ status: 'error', message: `업로드 오류: ${err.message}` });
|
||||
}
|
||||
else if (err) {
|
||||
console.error('[File Filter Error]:', err.message);
|
||||
return res.status(400).json({ status: 'error', message: err.message });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ status: 'error', message: '업로드할 파일을 찾을 수 없습니다.' });
|
||||
}
|
||||
|
||||
console.log(`[서버] 파일 업로드 성공:`);
|
||||
console.log(` - 원본 파일명: ${req.file.originalname}`);
|
||||
console.log(` - 저장 경로: ${req.file.path}`);
|
||||
|
||||
res.json({
|
||||
status: 'success',
|
||||
message: `파일 '${req.file.originalname}'이(가) 성공적으로 업로드되었습니다.`,
|
||||
filePath: req.file.path
|
||||
});
|
||||
});
|
||||
app.post('/upload-model', uploadModel.single('modelFile'), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' });
|
||||
res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path });
|
||||
});
|
||||
|
||||
// ========== 모델 목록 조회 로직 시작 ==========
|
||||
|
||||
/**
|
||||
* 버전 문자열을 비교합니다. (예: "v1.1" > "v1.0")
|
||||
*/
|
||||
function isVersionGreater(v1, v2) {
|
||||
if (!v2) return true;
|
||||
|
||||
const parts1 = v1.replace('v', '').split('.').map(Number);
|
||||
const parts2 = v2.replace('v', '').split('.').map(Number);
|
||||
|
||||
if (parts1[0] > parts2[0]) return true;
|
||||
if (parts1[0] < parts2[0]) return false;
|
||||
|
||||
if (parts1[1] > parts2[1]) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 7-1. 모델 목록 조회 엔드포인트
|
||||
app.get('/list-models', (req, res) => {
|
||||
const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/;
|
||||
const latestModels = {};
|
||||
|
||||
fs.readdir(uploadPath, (err, files) => {
|
||||
if (err) {
|
||||
console.error('[서버] 모델 디렉토리 읽기 실패:', err);
|
||||
return res.status(500).json({ status: 'error', message: '서버에서 파일 목록을 읽을 수 없습니다.' });
|
||||
}
|
||||
|
||||
if (err) return res.status(500).json({ status: 'error' });
|
||||
files.forEach(file => {
|
||||
const match = file.match(modelRegex);
|
||||
|
||||
if (match) {
|
||||
const role = match[1];
|
||||
const version = `v${match[2]}`;
|
||||
|
||||
const existing = latestModels[role];
|
||||
|
||||
if (!existing || isVersionGreater(version, existing.version)) {
|
||||
latestModels[role] = {
|
||||
file: file,
|
||||
version: version
|
||||
};
|
||||
latestModels[role] = { file: file, version: version };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[서버] 최신 모델 목록 조회:', latestModels);
|
||||
res.json(latestModels);
|
||||
});
|
||||
});
|
||||
// ========== 모델 목록 조회 로직 끝 ==========
|
||||
|
||||
|
||||
// ========== [추가됨] 모델 파일 삭제 엔드포인트 ==========
|
||||
app.post('/delete-model', (req, res) => {
|
||||
const { filename } = req.body; // { "filename": "CUUVA_..." }
|
||||
const { filename } = req.body;
|
||||
if (!filename) return res.status(400).json({ status: 'error', message: '파일명 누락' });
|
||||
const safeFilename = path.basename(filename);
|
||||
if (!safeFilename.endsWith('.aiwbin')) return res.status(400).json({ status: 'error', message: '잘못된 파일' });
|
||||
const filePath = path.join(uploadPath, safeFilename);
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err && err.code !== 'ENOENT') return res.status(500).json({ status: 'error' });
|
||||
res.json({ status: 'success', message: '삭제 완료' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ========================================================
|
||||
// [API] 관심 인물(POI) 관리 관련
|
||||
// ========================================================
|
||||
|
||||
app.get('/poi/list', (req, res) => {
|
||||
fs.readdir(vipTracksPath, { withFileTypes: true }, (err, entries) => {
|
||||
if (err) return res.json([]);
|
||||
|
||||
const result = entries
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => {
|
||||
const name = dirent.name;
|
||||
const dirPath = path.join(vipTracksPath, name);
|
||||
let imageFiles = [];
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dirPath);
|
||||
imageFiles = files.filter(file => /\.(jpg|jpeg|png)$/i.test(file));
|
||||
} catch (e) {
|
||||
imageFiles = [];
|
||||
}
|
||||
|
||||
return { name, images: imageFiles };
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/poi/register', (req, res) => {
|
||||
const { name } = req.body;
|
||||
const nameRegex = /^[a-zA-Z0-9]+$/;
|
||||
|
||||
if (!filename) {
|
||||
return res.status(400).json({ status: 'error', message: '파일 이름이 제공되지 않았습니다.' });
|
||||
if (!name || !nameRegex.test(name)) {
|
||||
return res.status(400).json({ status: 'error', message: '이름은 영문과 숫자만 가능합니다.' });
|
||||
}
|
||||
const targetDir = path.join(vipTracksPath, name);
|
||||
if (fs.existsSync(targetDir)) {
|
||||
return res.status(400).json({ status: 'error', message: '이미 등록된 이름입니다.' });
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(targetDir);
|
||||
res.json({ status: 'success' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: '폴더 생성 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// [보안] Directory Traversal 공격 방지
|
||||
// path.basename은 파일명에서 상위 디렉토리(.., /) 등을 제거합니다.
|
||||
const safeFilename = path.basename(filename);
|
||||
app.post('/poi/delete', (req, res) => {
|
||||
const { name } = req.body;
|
||||
const safeName = path.basename(name);
|
||||
const targetDir = path.join(vipTracksPath, safeName);
|
||||
|
||||
// 만약 ".." 같은 것이 포함되어 safeFilename이 원래 filename과 다르다면, 비정상 요청
|
||||
if (safeFilename !== filename) {
|
||||
return res.status(400).json({ status: 'error', message: '잘못된 파일 이름 형식입니다.' });
|
||||
try {
|
||||
if (fs.existsSync(targetDir)) {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
res.json({ status: 'success' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: '삭제 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// [보안] .aiwbin 파일만 삭제하도록 다시 한번 확인
|
||||
if (!safeFilename.endsWith('.aiwbin')) {
|
||||
return res.status(400).json({ status: 'error', message: '.aiwbin 파일만 삭제할 수 있습니다.' });
|
||||
app.post('/poi/delete-image', (req, res) => {
|
||||
const { name, imageName } = req.body;
|
||||
if (!name || !imageName) return res.status(400).json({ status: 'error', message: '정보 누락' });
|
||||
const safeName = path.basename(name);
|
||||
const safeImageName = path.basename(imageName);
|
||||
const targetFile = path.join(vipTracksPath, safeName, safeImageName);
|
||||
|
||||
if (fs.existsSync(targetFile)) {
|
||||
try {
|
||||
fs.unlinkSync(targetFile);
|
||||
res.json({ status: 'success', message: '이미지가 삭제되었습니다.' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ status: 'error', message: '파일 삭제 오류' });
|
||||
}
|
||||
} else {
|
||||
res.status(404).json({ status: 'error', message: '이미지가 없습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 최종 삭제할 파일 경로
|
||||
const filePath = path.join(uploadPath, safeFilename);
|
||||
// [수정됨] 다중 파일 업로드 처리 (uploadPoi.array 사용)
|
||||
app.post('/poi/upload-image', uploadPoi.array('poiFile'), (req, res) => {
|
||||
// 다중 파일인 경우 req.files에 배열로 들어옴
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ status: 'error', message: '파일 업로드 실패 (jpg, png만 가능)' });
|
||||
}
|
||||
|
||||
console.log(`[서버] 파일 삭제 시도: ${filePath}`);
|
||||
const poiName = req.body.poiName;
|
||||
|
||||
// fs.unlink로 파일 삭제
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
// 파일이 존재하지 않는 경우 (이미 삭제되었거나, 잘못된 요청)
|
||||
if (err.code === 'ENOENT') {
|
||||
console.error(`[서버] 삭제 실패: 파일 없음 ${filePath}`, err);
|
||||
return res.status(404).json({ status: 'error', message: '삭제할 파일을 서버에서 찾을 수 없습니다.' });
|
||||
}
|
||||
// 기타 오류 (예: 권한 문제)
|
||||
console.error(`[서버] 파일 삭제 오류 ${filePath}:`, err);
|
||||
return res.status(500).json({ status: 'error', message: '파일 삭제 중 서버 오류가 발생했습니다.' });
|
||||
// poiName이 없으면 임시 파일 모두 삭제
|
||||
if (!poiName) {
|
||||
req.files.forEach(file => {
|
||||
if(fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
||||
});
|
||||
return res.status(400).json({ status: 'error', message: '대상 인물 정보 누락' });
|
||||
}
|
||||
|
||||
const targetDir = path.join(vipTracksPath, poiName);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(targetDir)) {
|
||||
fs.mkdirSync(targetDir);
|
||||
}
|
||||
|
||||
// 삭제 성공
|
||||
console.log(`[서버] 파일 삭제 성공: ${filePath}`);
|
||||
res.json({ status: 'success', message: `파일 '${safeFilename}'(이)가 성공적으로 삭제되었습니다.` });
|
||||
});
|
||||
});
|
||||
// ========== 모델 파일 삭제 엔드포인트 끝 ==========
|
||||
// 업로드된 모든 파일을 순회하며 이동
|
||||
req.files.forEach(file => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
// 파일명 중복 방지를 위해 난수 추가
|
||||
const newFileName = `img_${Date.now()}_${Math.floor(Math.random() * 100000)}${ext}`;
|
||||
const targetPath = path.join(targetDir, newFileName);
|
||||
|
||||
fs.renameSync(file.path, targetPath);
|
||||
});
|
||||
|
||||
res.json({ status: 'success', message: '이미지 등록 완료' });
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// 에러 발생 시 남아있는 임시 파일 정리
|
||||
req.files.forEach(file => {
|
||||
if(fs.existsSync(file.path)) try { fs.unlinkSync(file.path); } catch(e){}
|
||||
});
|
||||
res.status(500).json({ status: 'error', message: '파일 이동 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
// 8. 설정한 3000번 포트에서 서버가 요청을 기다리도록 실행합니다.
|
||||
// 서버 실행
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
});
|
||||
//
|
||||
});
|
||||
Loading…
Reference in new issue