diff --git a/public/app.js b/public/app.js
index 273e8b0..636dbe7 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,608 +1,408 @@
// app.js
-// DOM(HTML)이 모두 로드되었을 때 실행
document.addEventListener('DOMContentLoaded', () => {
- // 탭 버튼과 콘텐츠 요소를 가져옵니다.
+ // =================================================
+ // 1. 탭 전환 로직
+ // =================================================
const tabVideo = document.getElementById('tab-video');
const tabModels = document.getElementById('tab-models');
-
const contentVideo = document.getElementById('content-video');
const contentModels = document.getElementById('content-models');
- // 비디오 탭 클릭 시
- if (tabVideo && tabModels) { // 탭 요소가 있는지 확인
+ if (tabVideo && tabModels) {
tabVideo.addEventListener('click', () => {
- // 버튼 활성화
tabVideo.classList.add('active');
tabModels.classList.remove('active');
-
- // 콘텐츠 표시
contentVideo.classList.add('active');
contentModels.classList.remove('active');
});
- }
- // AI 모델 탭 클릭 시
- if (tabVideo && tabModels) { // 탭 요소가 있는지 확인
tabModels.addEventListener('click', () => {
- // 버튼 활성화
tabModels.classList.add('active');
tabVideo.classList.remove('active');
-
- // 콘텐츠 표시
contentModels.classList.add('active');
contentVideo.classList.remove('active');
-
- // [추가] AI 모델 탭을 클릭할 때 목록 새로고침
loadModelList();
+ loadPoiList();
});
}
- // ========== AI 모델 목록 로드 함수 ==========
+ // =================================================
+ // 2. AI 모델 관리 로직
+ // =================================================
function loadModelList() {
- // 테이블 본문(tbody)을 선택
const tableBody = document.querySelector('.model-table tbody');
- if (!tableBody) return; // 테이블이 없으면 중단
-
- console.log('Loading model list...');
-
- fetch('/list-models') // server.js에 추가한 엔드포인트
- .then(response => response.json())
- .then(models => {
- // models = { "OBJDET": { "file": "...", "version": "..." }, ... }
-
- // 테이블의 모든 'data-role' 행을 순회
- tableBody.querySelectorAll('tr[data-role]').forEach(row => {
- const role = row.dataset.role; // e.g., "OBJDET"
- const modelData = models[role]; // 해당 역할의 모델 데이터 (없으면 undefined)
-
- const fileCell = row.querySelector('.model-filename');
- const versionCell = row.querySelector('.model-version');
- const deleteButton = row.querySelector('.btn-delete'); // [수정] 삭제 버튼 선택
-
- if (modelData) {
- // 일치하는 파일이 있으면, 파일명과 버전 업데이트
- if (fileCell) fileCell.textContent = modelData.file;
- if (versionCell) versionCell.textContent = modelData.version;
- // [수정] 삭제 버튼 표시
- if (deleteButton) deleteButton.classList.remove('hidden');
- } else {
- // 일치하는 파일이 없으면, 기본값 '-'으로 설정
- if (fileCell) fileCell.textContent = '-';
- if (versionCell) versionCell.textContent = '-';
- // [수정] 삭제 버튼 숨김
- if (deleteButton) deleteButton.classList.add('hidden');
- }
- });
- })
- .catch(error => {
- console.error('모델 목록 로드 실패:', error);
- alert('모델 목록을 불러오는 데 실패했습니다.');
+ if (!tableBody) return;
+ fetch('/list-models').then(r=>r.json()).then(models=>{
+ tableBody.querySelectorAll('tr[data-role]').forEach(row => {
+ const role = row.dataset.role;
+ const d = models[role];
+ const f = row.querySelector('.model-filename');
+ const v = row.querySelector('.model-version');
+ const btn = row.querySelector('.btn-delete');
+ if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); }
+ else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); }
});
+ });
}
- // ========== AI 모델 목록 로드 함수 끝 ==========
-
+ const modelTableBody = document.querySelector('.model-table tbody');
+ if(modelTableBody) {
+ modelTableBody.addEventListener('click', (e) => {
+ if(e.target.classList.contains('btn-delete')) {
+ const row = e.target.closest('tr');
+ const filename = row.querySelector('.model-filename').textContent;
+ if(filename && filename !== '-') deleteModelFile(filename);
+ }
+ });
+ }
- // ========== [추가됨] 모델 삭제 요청 함수 ==========
function deleteModelFile(filename) {
- console.log(`Requesting deletion of: ${filename}`);
-
- fetch('/delete-model', { // server.js에 추가할 엔드포인트
+ if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
+ fetch('/delete-model', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json'
- },
- body: JSON.stringify({filename: filename}) // { "filename": "CUUVA_..." }
- })
- .then(response => response.json())
- .then(data => {
- if (data.status === 'success') {
- alert(`파일이 성공적으로 삭제되었습니다: ${filename}`);
- loadModelList(); // 삭제 성공 시 목록 새로고침
- } else {
- alert(`삭제 실패: ${data.message}`);
- }
- })
- .catch(error => {
- console.error('Delete error:', error);
- alert('삭제 중 오류가 발생했습니다.');
- });
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({filename: filename})
+ }).then(res => res.json()).then(data => {
+ if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); }
+ else { alert(`삭제 실패: ${data.message}`); }
+ });
}
- // ========== 모델 삭제 요청 함수 끝 ==========
-
-
- // ========== AI 모델 업로드/관리 로직 시작 ==========
- if (contentModels) {
- // 전역 업로드 관련 요소 가져오기
- const globalFileInput = document.getElementById('global-file-input');
- const fileNameDisplay = document.getElementById('file-name-display');
- const globalUploadButton = document.getElementById('global-upload-button');
+ const globalUploadBtn = document.getElementById('global-upload-button');
+ const globalFileInput = document.getElementById('global-file-input');
+ const fileNameDisplay = document.getElementById('file-name-display');
+ const refreshButton = document.querySelector('.refresh-button');
- // 새로고침 버튼 요소
- const refreshButton = contentModels.querySelector('.refresh-button');
-
- // [추가] 모델 테이블 본문 (이벤트 위임용)
- const tableBody = contentModels.querySelector('.model-table tbody');
-
- // '찾아보기'로 파일 선택 시
- if (globalFileInput && fileNameDisplay) {
- globalFileInput.addEventListener('change', () => {
- if (globalFileInput.files.length > 0) {
- const file = globalFileInput.files[0];
-
- // 파일 확장자 확인
- if (!file.name.endsWith('.aiwbin')) {
- alert('잘못된 파일 형식입니다. .aiwbin 파일만 선택할 수 있습니다.');
- globalFileInput.value = ''; // 파일 선택 초기화
- fileNameDisplay.textContent = '선택된 파일 없음';
- } else {
- // 파일명 표시
- fileNameDisplay.textContent = file.name;
- }
+ if (globalFileInput) {
+ globalFileInput.addEventListener('change', () => {
+ fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음';
+ });
+ }
+ if (globalUploadBtn) {
+ globalUploadBtn.addEventListener('click', () => {
+ if (!globalFileInput.files.length) return alert('파일을 선택해주세요.');
+ const formData = new FormData();
+ formData.append('modelFile', globalFileInput.files[0]);
+ fetch('/upload-model', { method: 'POST', body: formData })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); }
+ else { alert(`실패: ${data.message}`); }
+ });
+ });
+ }
+ if (refreshButton) refreshButton.addEventListener('click', loadModelList);
+
+
+ // =================================================
+ // 3. 관심 인물(POI) 관리 로직
+ // =================================================
+ const poiTableBody = document.querySelector('#poi-table tbody');
+ const poiEmptyMsg = document.getElementById('poi-empty-msg');
+ const btnPoiRegister = document.getElementById('btn-poi-register');
+ const btnPoiRefresh = document.getElementById('btn-poi-refresh');
+
+ // 모달 및 뷰어 요소
+ const poiModal = document.getElementById('poi-modal');
+ const poiNameInput = document.getElementById('poi-name-input');
+ const btnModalConfirm = document.getElementById('btn-modal-confirm');
+ const btnModalCancel = document.getElementById('btn-modal-cancel');
+
+ const imageViewerModal = document.getElementById('image-viewer-modal');
+ const fullImage = document.getElementById('full-image');
+ const closeViewerBtn = document.querySelector('.close-viewer');
+
+ // POI 목록 로드
+ function loadPoiList() {
+ if (!poiTableBody) return;
+
+ fetch('/poi/list')
+ .then(res => res.json())
+ .then(list => {
+ poiTableBody.innerHTML = '';
+
+ if (!list || list.length === 0) {
+ poiEmptyMsg.classList.remove('hidden');
+ poiTableBody.parentElement.classList.add('hidden');
} else {
- fileNameDisplay.textContent = '선택된 파일 없음';
- }
- });
- }
+ poiEmptyMsg.classList.add('hidden');
+ poiTableBody.parentElement.classList.remove('hidden');
+
+ list.forEach((item, index) => {
+ const tr = document.createElement('tr');
+
+ let previewHtml = '-';
+ if (item.images && item.images.length > 0) {
+ const imagesHtml = item.images.map(imgName => {
+ const imgSrc = `/poi-images/${item.name}/${imgName}`;
+ return `
+
(행)을 찾습니다.
- const row = event.target.closest('tr');
- if (!row) return;
+ // [수정됨] 다중 파일 업로드 처리
+ window.uploadPoiImage = function(name, input) {
+ if (!input.files || input.files.length === 0) return;
- // 행에서 파일명 셀(.model-filename)을 찾아 파일명을 가져옵니다.
- const fileCell = row.querySelector('.model-filename');
- const filename = fileCell ? fileCell.textContent : null;
+ const formData = new FormData();
+ formData.append('poiName', name);
- // 파일명이 없거나 '-' 이면 (파일이 할당되지 않음) 중단
- if (!filename || filename === '-') {
- alert('삭제할 파일이 없습니다.');
- return;
- }
+ // 선택된 모든 파일을 formData에 추가
+ for (let i = 0; i < input.files.length; i++) {
+ const file = input.files[i];
+ const ext = file.name.split('.').pop().toLowerCase();
- // 행의 두 번째 셀()에서 역할 텍스트를 가져옵니다.
- const roleText = row.cells[1] ? row.cells[1].textContent : '알 수 없는 역할';
+ if (!['jpg', 'jpeg', 'png'].includes(ext)) {
+ alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
+ input.value = '';
+ return;
+ }
- // 사용자에게 삭제 확인을 받습니다.
- if (confirm(`정말로 이 모델 파일을 삭제하시겠습니까?\n\n역할: ${roleText}\n파일: ${filename}`)) {
- // 확인 시, 삭제 함수 호출
- deleteModelFile(filename);
- }
- }
- });
+ formData.append('poiFile', file);
}
- // ========== 삭제 버튼 이벤트 리스너 끝 ==========
- }
- // ========== AI 모델 업로드/관리 로직 끝 ==========
+ fetch('/poi/upload-image', { method: 'POST', body: formData })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') {
+ alert('이미지 등록 완료');
+ loadPoiList();
+ } else {
+ alert('업로드 실패: ' + (data.message || '오류'));
+ }
+ })
+ .catch(() => alert('오류 발생'));
+ input.value = '';
+ };
- // ========== 로그아웃 버튼 처리 ==========
- const logoutButton = document.getElementById('logout-button');
- if (logoutButton) { // 로그아웃 버튼이 있는지 확인
- logoutButton.addEventListener('click', () => {
- console.log('Logout clicked');
+ // 이벤트 리스너
+ if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
- // [수정됨] 사용자에게 확인을 받습니다.
- if (confirm('정말로 로그아웃하시겠습니까?')) {
- // 확인을 누르면 로그인 페이지(루트 '/')로 이동
- window.location.href = '/';
- }
- // 취소를 누르면 아무 일도 일어나지 않습니다.
+ if (btnPoiRegister) {
+ btnPoiRegister.addEventListener('click', () => {
+ poiNameInput.value = '';
+ poiModal.classList.remove('hidden');
});
}
- // ========== 로그아웃 버튼 처리 끝 ==========
+ if (btnModalCancel) {
+ btnModalCancel.addEventListener('click', () => {
+ poiModal.classList.add('hidden');
+ });
+ }
- // ========== dashboard.html 스크립트 추가 시작 ==========
- const uri = "ws://10.10.11.246:8765"; // 필요하면 여기만 수정
+ if (btnModalConfirm) {
+ btnModalConfirm.addEventListener('click', () => {
+ const name = poiNameInput.value.trim();
+ if (!name) return alert('이름을 입력하세요.');
+ const regex = /^[a-zA-Z0-9]+$/;
+ if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
+
+ fetch('/poi/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name })
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') {
+ poiModal.classList.add('hidden');
+ loadPoiList();
+ } else {
+ alert(data.message || '등록 실패');
+ }
+ });
+ });
+ }
- // [수정] imgEl -> canvasEl로 변경
+ // =================================================
+ // 4. 비디오 & 웹소켓 (기존 기능)
+ // =================================================
+ const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
-
const statusEl = document.getElementById("status");
- const frameInfoEl = document.getElementById("frame-info");
const detListEl = document.getElementById("det-list");
const bboxContainerEl = document.getElementById("bbox-container");
const modelContainerEl = document.getElementById("current-model-container");
-
- // [추가] 오버레이 표시용 요소
const modelDisplayEl = document.getElementById("current-model-display");
const fpsDisplayEl = document.getElementById("fps-display");
-
- // [추가] 캔버스 2D 컨텍스트
let ctx = null;
- if (canvasEl) {
- ctx = canvasEl.getContext('2d');
- }
- // [추가] 클래스별 색상 팔레트 (원하는 색상으로 수정 가능)
- const CLASS_COLORS = [
- '#FF3838', // 0: Red
- '#38BFFF', // 1: Blue
- '#38FF4E', // 2: Green
- '#FFF238', // 3: Yellow
- '#FF9D38', // 4: Orange
- '#C538FF', // 5: Purple
- '#FF38C5', // 6: Pink
- '#FFFFFF' // 7: White
- ];
-
- // [추가] FPS 계산용 변수
+ if (canvasEl) ctx = canvasEl.getContext('2d');
let frameCount = 0;
let lastFpsCheckTime = performance.now();
-
let lastFrameMeta = null;
- // [수정] canvasEl 및 ctx 존재 여부 확인
- if (canvasEl && ctx && statusEl && frameInfoEl && detListEl && bboxContainerEl && modelContainerEl) {
+ function connect() {
+ const ws = new WebSocket(uri);
+ ws.binaryType = "arraybuffer";
+ ws.onopen = () => { statusEl.textContent = "연결됨"; statusEl.className = "status"; };
+ ws.onclose = () => { statusEl.textContent = "연결 종료. 재접속..."; statusEl.className = "status err"; setTimeout(connect, 2000); };
+ ws.onmessage = (event) => {
+ const data = event.data;
+ if (typeof data === "string") {
+ try {
+ const meta = JSON.parse(data);
+ if (meta.type === "frame") {
+ lastFrameMeta = meta;
+ document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
+ } else if (meta.type === "det") {
+ showDetections(meta);
+ }
+ } catch(e){}
+ } else if (data instanceof ArrayBuffer && lastFrameMeta) {
+ frameCount++;
+ const now = performance.now();
+ if (now - lastFpsCheckTime >= 1000) {
+ fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
+ frameCount = 0;
+ lastFpsCheckTime = now;
+ }
+ const blob = new Blob([data], {type: "image/jpeg"});
+ createImageBitmap(blob).then(bmp => {
+ canvasEl.width = canvasEl.clientWidth;
+ canvasEl.height = canvasEl.clientHeight;
+ const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
+ const dw = bmp.width * r;
+ const dh = bmp.height * r;
+ const dx = (canvasEl.width - dw) / 2;
+ const dy = (canvasEl.height - dh) / 2;
+ ctx.clearRect(0,0,canvasEl.width, canvasEl.height);
+ ctx.drawImage(bmp, dx, dy, dw, dh);
+ bmp.close();
+ updateBboxContainerPosition();
+ });
+ }
+ };
+ }
- // 모델 변경 이벤트 리스너
- modelContainerEl.addEventListener('change', (event) => {
- if (event.target && event.target.name === 'current-model') {
- // [추가] 모델 이름 즉시 업데이트
- updateModelDisplay();
+ function showDetections(meta) {
+ bboxContainerEl.innerHTML = "";
+ const items = meta.items || [];
+ let lines = [];
+ items.forEach((it, i) => {
+ lines.push(`#${i} cls=${it.cls} score=${it.conf || 0}`);
+ });
+ detListEl.textContent = lines.join("\n");
+ }
- const selectedModel = event.target.value;
- console.log(`Model changed to: ${selectedModel}`);
+ function updateBboxContainerPosition() {
+ if (!canvasEl || !bboxContainerEl) return;
+ bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
+ bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
+ bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
+ bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
+ }
+ if (modelContainerEl) {
+ modelContainerEl.addEventListener('change', (e) => {
+ if(e.target.name === 'current-model') {
+ updateModelDisplay();
fetch('/set-model', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({model: selectedModel}),
- })
- .then(response => response.json())
- .then(data => {
- console.log('Server response:', data);
- if (data.status === 'success') {
- logStatus(`모델 변경 완료: ${selectedModel}`);
- } else {
- logStatus(`모델 변경 실패: ${data.message}`, true);
- }
- })
- .catch(error => {
- console.error('Error setting model:', error);
- logStatus('모델 변경 중 오류 발생', true);
- });
- }
- });
-
- // 박스 컨테이너 위치/크기 조절 함수
- function updateBboxContainerPosition() {
- // [수정] imgEl -> canvasEl
- if (!canvasEl || !bboxContainerEl) return;
- const top = canvasEl.offsetTop;
- const left = canvasEl.offsetLeft;
- const width = canvasEl.offsetWidth;
- const height = canvasEl.offsetHeight;
- bboxContainerEl.style.top = `${top}px`;
- bboxContainerEl.style.left = `${left}px`;
- bboxContainerEl.style.width = `${width}px`;
- bboxContainerEl.style.height = `${height}px`;
- }
-
- function logStatus(msg, isError = false) {
- statusEl.textContent = msg;
- statusEl.className = "status" + (isError ? " err" : "");
- }
-
- function showFrameMeta(meta) {
- frameInfoEl.textContent =
- ` | FRAME ch=${meta.ch} ts=${meta.ts_us} w=${meta.w} h=${meta.h}`;
- }
-
- // [추가] 선택된 AI 모델 이름을 상단 우측에 표시하는 함수
- function updateModelDisplay() {
- if (!modelContainerEl || !modelDisplayEl) return;
-
- // 현재 선택된 라디오 버튼을 찾습니다.
- const checkedRadio = modelContainerEl.querySelector('input[name="current-model"]:checked');
- if (checkedRadio) {
- // 해당 라디오 버튼의 ID로 label을 찾습니다.
- const label = modelContainerEl.querySelector(`label[for="${checkedRadio.id}"]`);
- if (label) {
- modelDisplayEl.textContent = label.textContent; // "군중 위험 인식"
- } else {
- modelDisplayEl.textContent = checkedRadio.value; // "CROWD" (대체)
- }
- } else {
- modelDisplayEl.textContent = "모델 선택 안됨";
- }
- }
-
-// Detections 표시 함수 (수정됨)
- function showDetections(meta) {
- const items = meta.items || [];
- let lines = [];
- lines.push(`DET ch=${meta.ch} seq=${meta.seq} ts=${meta.ts_us} cnt=${items.length}`);
- bboxContainerEl.innerHTML = "";
-
- // [수정] imgEl -> canvasEl
- if (!canvasEl || !lastFrameMeta) {
- // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정
- items.forEach((it, i) => {
- // x1, y1, x2, y2 -> x, y, w, h
- const x = it.x1;
- const y = it.y1;
- const w = it.x2 - it.x1;
- const h = it.y2 - it.y1;
-
- lines.push(
- `#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
- + ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
- + ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
- );
- });
- detListEl.textContent = lines.join("\n");
- return;
- }
-
- updateBboxContainerPosition();
- // [수정] imgEl -> canvasEl
- const imgWidth = canvasEl.clientWidth;
- const imgHeight = canvasEl.clientHeight;
- const frameWidth = lastFrameMeta.w;
- const frameHeight = lastFrameMeta.h;
-
- if (frameWidth === 0 || frameHeight === 0 || imgWidth === 0 || imgHeight === 0) {
- // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정
- items.forEach((it, i) => {
- // x1, y1, x2, y2 -> x, y, w, h
- const x = it.x1;
- const y = it.y1;
- const w = it.x2 - it.x1;
- const h = it.y2 - it.y1;
-
- lines.push(
- `#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
- + ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
- + ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
- );
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({model: e.target.value})
});
- detListEl.textContent = lines.join("\n");
- return;
}
+ });
+ }
- const widthRatio = imgWidth / frameWidth;
- const heightRatio = imgHeight / frameHeight;
- const ratio = Math.min(widthRatio, heightRatio);
- const videoDisplayWidth = frameWidth * ratio;
- const videoDisplayHeight = frameHeight * ratio;
- const offsetX = (imgWidth - videoDisplayWidth) / 2;
- const offsetY = (imgHeight - videoDisplayHeight) / 2;
-
- // [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 및 박스 계산 수정
- items.forEach((it, i) => {
- // x1, y1, x2, y2 -> x, y, w, h
- const x = it.x1;
- const y = it.y1;
- const w = it.x2 - it.x1;
- const h = it.y2 - it.y1;
-
- // [추가] 현재 항목의 클래스(cls) 가져오기
- const cls = it.cls;
-
- lines.push(
- `#${i} cls=${cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
- + ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
- + ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
- );
-
- // 계산된 x, y, w, h를 사용하여 박스 위치 계산
- const boxLeft = (x * ratio) + offsetX;
- const boxTop = (y * ratio) + offsetY;
- const boxWidth = w * ratio;
- const boxHeight = h * ratio;
-
- // [추가] 클래스(cls) 값에 따라 색상 결정
- // cls를 정수로 변환 (혹시 문자열일 경우 대비)
- const classIndex = parseInt(cls, 10) || 0;
- // 모듈러(%) 연산을 사용해 색상 배열을 순환
- const color = CLASS_COLORS[classIndex % CLASS_COLORS.length];
-
- const bbox = document.createElement('div');
- bbox.className = 'bbox';
- bbox.style.left = `${boxLeft}px`;
- bbox.style.top = `${boxTop}px`;
- bbox.style.width = `${boxWidth}px`;
- bbox.style.height = `${boxHeight}px`;
-
- // [추가] CSS의 'border' 대신 'borderColor'를 동적으로 설정
- // 이렇게 하면 style.css의 'border-width', 'border-style'은 유지됩니다.
- bbox.style.borderColor = color;
-
- bboxContainerEl.appendChild(bbox);
- });
- detListEl.textContent = lines.join("\n");
- }
-
- // WebSocket 연결 함수
- function connect() {
- const ws = new WebSocket(uri);
- ws.binaryType = "arraybuffer";
- ws.onopen = () => {
- logStatus(`연결됨: ${uri}`);
- };
- ws.onclose = (ev) => {
- logStatus(`연결 종료 (code=${ev.code}) 재접속 대기중...`, true);
- setTimeout(connect, 2000);
- };
- ws.onerror = (err) => {
- logStatus("WebSocket 오류 발생", true);
- console.error(err);
- };
- ws.onmessage = (event) => {
- const data = event.data;
- if (typeof data === "string") {
- try {
- const meta = JSON.parse(data);
- const t = meta.type;
- if (t === "frame") {
- lastFrameMeta = meta;
- showFrameMeta(meta);
- } else if (t === "det") {
- showDetections(meta);
- }
- } catch (e) {
- console.error("JSON parse error:", e, data);
- }
- return;
- }
-
- // [수정] ArrayBuffer 처리: img.src 대신 createImageBitmap 및 canvas.drawImage 사용
- if (data instanceof ArrayBuffer && lastFrameMeta) {
-
- // ========== [수정됨] FPS 계산 로직 (이곳으로 이동) ==========
- // WebSocket에서 ArrayBuffer(프레임)를 수신한 시점을 기준으로 FPS 계산
- frameCount++;
- const now = performance.now();
- const delta = now - lastFpsCheckTime;
-
- if (delta >= 1000 && fpsDisplayEl) { // 1초마다 업데이트
- const fps = (frameCount * 1000) / delta;
- fpsDisplayEl.textContent = `${Math.round(fps)} FPS`;
- frameCount = 0;
- lastFpsCheckTime = now;
- }
- // ========== FPS 계산 로직 끝 ==========
-
-
- const blob = new Blob([data], {type: "image/jpeg"});
-
- // createImageBitmap으로 비동기 디코딩 및 렌더링
- createImageBitmap(blob)
- .then(imageBitmap => {
- // 캔버스 크기를 CSS에 정의된 표시 크기로 맞춥니다.
- // (object-fit: contain을 시뮬레이션하기 위해 매번 필요)
- canvasEl.width = canvasEl.clientWidth;
- canvasEl.height = canvasEl.clientHeight;
-
- const canvasWidth = canvasEl.width;
- const canvasHeight = canvasEl.height;
- const frameWidth = imageBitmap.width; // 실제 이미지 비트맵의 너비
- const frameHeight = imageBitmap.height; // 실제 이미지 비트맵의 높이
-
- // 'object-fit: contain' 로직
- const widthRatio = canvasWidth / frameWidth;
- const heightRatio = canvasHeight / frameHeight;
- const ratio = Math.min(widthRatio, heightRatio);
-
- const videoDisplayWidth = frameWidth * ratio;
- const videoDisplayHeight = frameHeight * ratio;
- const offsetX = (canvasWidth - videoDisplayWidth) / 2;
- const offsetY = (canvasHeight - videoDisplayHeight) / 2;
-
- // 캔버스를 지웁니다 (CSS의 background-color가 비친다)
- ctx.clearRect(0, 0, canvasWidth, canvasHeight);
-
- // 계산된 위치에 이미지를 그립니다.
- ctx.drawImage(imageBitmap, offsetX, offsetY, videoDisplayWidth, videoDisplayHeight);
-
- // 비트맵 메모리 해제
- imageBitmap.close();
-
- // 프레임이 그려진 *이후*에 바운딩 박스 컨테이너 위치 업데이트
- updateBboxContainerPosition();
-
- // [수정됨] FPS 계산 로직이 위로 이동했으므로 여기서는 제거됩니다.
-
- })
- .catch(e => {
- console.error("createImageBitmap error:", e);
- });
- }
- };
+ function updateModelDisplay() {
+ if(!modelContainerEl || !modelDisplayEl) return;
+ const checked = modelContainerEl.querySelector('input:checked');
+ if(checked) {
+ const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
+ modelDisplayEl.textContent = label ? label.textContent : checked.value;
}
+ }
- // [추가] 초기 모델 이름 설정
- updateModelDisplay();
-
- // WebSocket 연결 시작
- connect();
-
- // 창 크기 변경 시 이벤트
- window.addEventListener('resize', updateBboxContainerPosition);
-
- } // if (요소 확인) 끝
- // ========== video-test.html 스크립트 추가 끝 ==========
-
-
- // ========== 페이지 로드 시 모델 목록 즉시 로드 ==========
+ updateModelDisplay();
+ if (canvasEl) connect();
+ window.addEventListener('resize', updateBboxContainerPosition);
+ loadPoiList();
loadModelList();
});
\ No newline at end of file
diff --git a/public/dashboard.html b/public/dashboard.html
index 3e7f236..9b6da20 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -20,20 +20,15 @@
-
-
-
@@ -65,13 +60,12 @@
-
+
+
+ 관심인물 등록
+
+
+
+
+
+
+
+
+
+
+
+ ×
+ ![]()
+
+
|