diff --git a/_server.js b/_server.js
new file mode 100644
index 0000000..2221c47
--- /dev/null
+++ b/_server.js
@@ -0,0 +1,306 @@
+// server.js
+
+// 1. 모듈 불러오기
+const express = require('express');
+const path = require('path');
+const { spawn } = require('child_process');
+const multer = require('multer');
+const fs = require('fs');
+
+// 2. Express 앱 생성
+const app = express();
+const port = 3000;
+
+// ========================================================
+// [설정] 경로 및 폴더 초기화
+// ========================================================
+
+// AI 모델용 업로드 경로
+const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
+fs.mkdirSync(uploadPath, { recursive: true });
+
+// 관심 인물(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); }
+});
+
+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);
+};
+
+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);
+};
+
+const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter });
+
+
+// ========================================================
+// [미들웨어 및 라우트 설정]
+// ========================================================
+
+app.use(express.static(path.join(__dirname, 'public')));
+app.use(express.json());
+
+// 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'));
+});
+
+app.get('/dashboard', (req, res) => {
+ res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
+});
+
+
+// ========================================================
+// [API] AI 모델 관련
+// ========================================================
+
+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];
+ const scriptProcess = spawn(scriptCommand, args);
+
+ let stdoutData = '';
+ scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); });
+
+ scriptProcess.on('close', (code) => {
+ if (code === 0) {
+ res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData });
+ } else {
+ res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' });
+ }
+ });
+});
+
+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 });
+});
+
+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;
+}
+
+app.get('/list-models', (req, res) => {
+ const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/;
+ const latestModels = {};
+
+ fs.readdir(uploadPath, (err, files) => {
+ 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 };
+ }
+ }
+ });
+ res.json(latestModels);
+ });
+});
+
+app.post('/delete-model', (req, res) => {
+ 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 (!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: '폴더 생성 실패' });
+ }
+});
+
+app.post('/poi/delete', (req, res) => {
+ const { name } = req.body;
+ const safeName = path.basename(name);
+ const targetDir = path.join(vipTracksPath, safeName);
+
+ 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: '삭제 실패' });
+ }
+});
+
+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: '이미지가 없습니다.' });
+ }
+});
+
+// [수정됨] 다중 파일 업로드 처리 (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만 가능)' });
+ }
+
+ const poiName = req.body.poiName;
+
+ // 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);
+ }
+
+ // 업로드된 모든 파일을 순회하며 이동
+ 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: '파일 이동 실패' });
+ }
+});
+
+// 서버 실행
+app.listen(port, () => {
+ console.log(`Server running on http://localhost:${port}`);
+});
\ No newline at end of file
diff --git a/public/_app.js b/public/_app.js
new file mode 100644
index 0000000..e275df3
--- /dev/null
+++ b/public/_app.js
@@ -0,0 +1,610 @@
+// app.js
+
+document.addEventListener('DOMContentLoaded', () => {
+
+ // =================================================
+ // 0. 로그아웃 로직
+ // =================================================
+ const logoutBtn = document.getElementById('logout-button');
+ if (logoutBtn) {
+ logoutBtn.addEventListener('click', () => {
+ if (confirm('정말로 로그아웃 하시겠습니까?')) {
+ // 로그인 페이지(루트)로 이동
+ window.location.href = '/';
+ }
+ });
+ }
+
+ // =================================================
+ // 1. 탭 전환 로직
+ // =================================================
+ const tabVideo = document.getElementById('tab-video');
+ const tabModels = document.getElementById('tab-models');
+ const contentVideo = document.getElementById('content-video');
+ const contentModels = document.getElementById('content-models');
+
+ if (tabVideo && tabModels) {
+ tabVideo.addEventListener('click', () => {
+ tabVideo.classList.add('active');
+ tabModels.classList.remove('active');
+ contentVideo.classList.add('active');
+ contentModels.classList.remove('active');
+ });
+
+ tabModels.addEventListener('click', () => {
+ tabModels.classList.add('active');
+ tabVideo.classList.remove('active');
+ contentModels.classList.add('active');
+ contentVideo.classList.remove('active');
+ loadModelList();
+ loadPoiList();
+ });
+ }
+
+ // =================================================
+ // 2. AI 모델 관리 로직
+ // =================================================
+ function loadModelList() {
+ const tableBody = document.querySelector('.model-table tbody');
+ if (!tableBody) return;
+ fetch('/list-models').then(r=>r.json()).then(models=>{
+ tableBody.querySelectorAll('tr[data-role]').forEach(row => {
+ const role = row.dataset.role;
+ const d = models[role];
+ const f = row.querySelector('.model-filename');
+ const v = row.querySelector('.model-version');
+ const btn = row.querySelector('.btn-delete');
+ if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); }
+ else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); }
+ });
+ });
+ }
+
+ const modelTableBody = document.querySelector('.model-table tbody');
+ if(modelTableBody) {
+ modelTableBody.addEventListener('click', (e) => {
+ if(e.target.classList.contains('btn-delete')) {
+ const row = e.target.closest('tr');
+ const filename = row.querySelector('.model-filename').textContent;
+ if(filename && filename !== '-') deleteModelFile(filename);
+ }
+ });
+ }
+
+ function deleteModelFile(filename) {
+ if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
+ fetch('/delete-model', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({filename: filename})
+ }).then(res => res.json()).then(data => {
+ if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); }
+ else { alert(`삭제 실패: ${data.message}`); }
+ });
+ }
+
+ const globalUploadBtn = document.getElementById('global-upload-button');
+ const globalFileInput = document.getElementById('global-file-input');
+ const fileNameDisplay = document.getElementById('file-name-display');
+ const refreshButton = document.querySelector('.refresh-button');
+
+ if (globalFileInput) {
+ globalFileInput.addEventListener('change', () => {
+ fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음';
+ });
+ }
+ if (globalUploadBtn) {
+ globalUploadBtn.addEventListener('click', () => {
+ if (!globalFileInput.files.length) return alert('파일을 선택해주세요.');
+ const formData = new FormData();
+ formData.append('modelFile', globalFileInput.files[0]);
+ fetch('/upload-model', { method: 'POST', body: formData })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); }
+ else { alert(`실패: ${data.message}`); }
+ });
+ });
+ }
+ if (refreshButton) refreshButton.addEventListener('click', loadModelList);
+
+
+ // =================================================
+ // 3. 관심 인물(POI) 관리 로직
+ // =================================================
+ const poiTableBody = document.querySelector('#poi-table tbody');
+ const poiEmptyMsg = document.getElementById('poi-empty-msg');
+ const btnPoiRegister = document.getElementById('btn-poi-register');
+ const btnPoiRefresh = document.getElementById('btn-poi-refresh');
+
+ // 모달 및 뷰어 요소
+ const poiModal = document.getElementById('poi-modal');
+ const poiNameInput = document.getElementById('poi-name-input');
+ const btnModalConfirm = document.getElementById('btn-modal-confirm');
+ const btnModalCancel = document.getElementById('btn-modal-cancel');
+
+ const imageViewerModal = document.getElementById('image-viewer-modal');
+ const fullImage = document.getElementById('full-image');
+ const closeViewerBtn = document.querySelector('.close-viewer');
+
+ // POI 목록 로드
+ function loadPoiList() {
+ if (!poiTableBody) return;
+
+ fetch('/poi/list')
+ .then(res => res.json())
+ .then(list => {
+ poiTableBody.innerHTML = '';
+
+ if (!list || list.length === 0) {
+ poiEmptyMsg.classList.remove('hidden');
+ poiTableBody.parentElement.classList.add('hidden');
+ } else {
+ poiEmptyMsg.classList.add('hidden');
+ poiTableBody.parentElement.classList.remove('hidden');
+
+ list.forEach((item, index) => {
+ const tr = document.createElement('tr');
+
+ let previewHtml = '-';
+ if (item.images && item.images.length > 0) {
+ const imagesHtml = item.images.map(imgName => {
+ const imgSrc = `/poi-images/${item.name}/${imgName}`;
+ return `
+
+

+
+
+ `;
+ }).join('');
+ previewHtml = `${imagesHtml}
`;
+ }
+
+ const fileInputId = `poi-file-${item.name}`;
+
+ tr.innerHTML = `
+ ${index + 1} |
+
+ ${item.name}
+
+ |
+ ${previewHtml} |
+
+
+
+ |
+ `;
+ poiTableBody.appendChild(tr);
+ });
+ }
+ })
+ .catch(err => console.error('POI Load Error:', err));
+ }
+
+ // [전역 함수] 이미지 뷰어 열기
+ window.openImageViewer = function(src) {
+ if (fullImage && imageViewerModal) {
+ fullImage.src = src;
+ imageViewerModal.classList.remove('hidden');
+ }
+ };
+
+ // 뷰어 닫기 로직
+ if (closeViewerBtn) {
+ closeViewerBtn.addEventListener('click', () => {
+ imageViewerModal.classList.add('hidden');
+ });
+ }
+ window.addEventListener('click', (e) => {
+ if (e.target === imageViewerModal) {
+ imageViewerModal.classList.add('hidden');
+ }
+ });
+
+
+ // [전역 함수] 인물 전체 삭제
+ window.deletePoi = function(name) {
+ if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return;
+
+ fetch('/poi/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name })
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') loadPoiList();
+ else alert('삭제 실패');
+ });
+ };
+
+ // [전역 함수] 특정 이미지만 삭제
+ window.deletePoiImage = function(name, imageName) {
+ if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return;
+
+ fetch('/poi/delete-image', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, imageName })
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') loadPoiList();
+ else alert('이미지 삭제 실패');
+ });
+ };
+
+ // 다중 파일 업로드 처리
+ window.uploadPoiImage = function(name, input) {
+ if (!input.files || input.files.length === 0) return;
+
+ const formData = new FormData();
+ formData.append('poiName', name);
+
+ for (let i = 0; i < input.files.length; i++) {
+ const file = input.files[i];
+ const ext = file.name.split('.').pop().toLowerCase();
+
+ if (!['jpg', 'jpeg', 'png'].includes(ext)) {
+ alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
+ input.value = '';
+ return;
+ }
+ formData.append('poiFile', file);
+ }
+
+ fetch('/poi/upload-image', { method: 'POST', body: formData })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') {
+ alert('이미지 등록 완료');
+ loadPoiList();
+ } else {
+ alert('업로드 실패: ' + (data.message || '오류'));
+ }
+ })
+ .catch(() => alert('오류 발생'));
+
+ input.value = '';
+ };
+
+
+ // 이벤트 리스너
+ if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
+
+ if (btnPoiRegister) {
+ btnPoiRegister.addEventListener('click', () => {
+ poiNameInput.value = '';
+ poiModal.classList.remove('hidden');
+ });
+ }
+
+ if (btnModalCancel) {
+ btnModalCancel.addEventListener('click', () => {
+ poiModal.classList.add('hidden');
+ });
+ }
+
+ if (btnModalConfirm) {
+ btnModalConfirm.addEventListener('click', () => {
+ const name = poiNameInput.value.trim();
+ if (!name) return alert('이름을 입력하세요.');
+ const regex = /^[a-zA-Z0-9]+$/;
+ if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
+
+ fetch('/poi/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name })
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.status === 'success') {
+ poiModal.classList.add('hidden');
+ loadPoiList();
+ } else {
+ alert(data.message || '등록 실패');
+ }
+ });
+ });
+ }
+
+ // =================================================
+ // 4. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
+ // =================================================
+
+ // [설정] Tag/Class 매핑 테이블
+ const LABEL_MAP = {
+ 1: {
+ tagName: "객체 탐지/ 분류",
+ classes: {
+ 0: "person",
+ 1: "car",
+ 2: "van",
+ 3: "truck",
+ 4: "bus",
+ 5: "motor"
+ }
+ },
+ 2: {
+ tagName: "화재 인식",
+ classes: {
+ 0: "flame",
+ 1: "smoke"
+ }
+ },
+ 3: {
+ tagName: "얼굴 인식",
+ classes: {
+ 0: "face"
+ }
+ },
+ 4: {
+ tagName: "차량번호판 및 차종 인식",
+ classes: {
+ 0: "License plate"
+ }
+ }
+ };
+
+ // [설정] 박스 색상 생성 함수
+ function getBoxColor(tag, cls) {
+ // Tag 2: 화재 관련 (긴급한 색상)
+ if (tag === 2) {
+ if (cls === 0) return '#FF0000'; // Flame: 빨강
+ if (cls === 1) return '#808080'; // Smoke: 회색
+ }
+
+ // Tag 1: 객체 탐지 (다양한 색상)
+ if (tag === 1) {
+ const colors = [
+ '#00FF00', // Person: 라임 그린
+ '#00FFFF', // Car: 시안
+ '#FFA500', // Van: 오렌지
+ '#FF69B4', // Truck: 핫핑크
+ '#9370DB', // Bus: 미디엄 퍼플
+ '#FFD700' // Motor: 골드
+ ];
+ return colors[cls % colors.length] || '#FFFFFF';
+ }
+
+ // Tag 3: 얼굴
+ if (tag === 3) return '#1E90FF'; // 도저 블루
+
+ // Tag 4: 번호판
+ if (tag === 4) return '#32CD32'; // 라임 그린
+
+ // 기타 기본값
+ return '#FF0000';
+ }
+
+ const uri = "ws://10.10.11.246:8765";
+ const canvasEl = document.getElementById("frame");
+ const statusEl = document.getElementById("status");
+ const detListEl = document.getElementById("det-list");
+ const bboxContainerEl = document.getElementById("bbox-container");
+ const modelContainerEl = document.getElementById("current-model-container");
+ const modelDisplayEl = document.getElementById("current-model-display");
+ const fpsDisplayEl = document.getElementById("fps-display");
+ let ctx = null;
+
+ if (canvasEl) ctx = canvasEl.getContext('2d');
+ let frameCount = 0;
+ let lastFpsCheckTime = performance.now();
+ let lastFrameMeta = null;
+
+ let viewConfig = { r: 1, dx: 0, dy: 0 };
+
+ function connect() {
+ const ws = new WebSocket(uri);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ statusEl.textContent = "연결됨";
+ statusEl.className = "status";
+ };
+
+ ws.onclose = () => {
+ statusEl.textContent = "연결 종료. 재접속...";
+ statusEl.className = "status err";
+ setTimeout(connect, 2000);
+ };
+
+ ws.onmessage = (event) => {
+ const data = event.data;
+
+ // 1. 텍스트 데이터(JSON) 처리
+ if (typeof data === "string") {
+ console.log("-----" + data); // 필요시 주석 해제
+ try {
+ const meta = JSON.parse(data);
+ if (meta.type === "frame") {
+ lastFrameMeta = meta;
+ document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
+ showDetections(meta);
+ }
+ } catch(e){ console.error(e); }
+
+ // 2. 바이너리 데이터(영상 프레임) 처리
+ } else if (data instanceof ArrayBuffer && lastFrameMeta) {
+ frameCount++;
+ const now = performance.now();
+ if (now - lastFpsCheckTime >= 1000) {
+ fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
+ frameCount = 0;
+ lastFpsCheckTime = now;
+ }
+
+ const blob = new Blob([data], {type: "image/jpeg"});
+ createImageBitmap(blob).then(bmp => {
+ canvasEl.width = canvasEl.clientWidth;
+ canvasEl.height = canvasEl.clientHeight;
+
+ const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
+ const dw = bmp.width * r;
+ const dh = bmp.height * r;
+ const dx = (canvasEl.width - dw) / 2;
+ const dy = (canvasEl.height - dh) / 2;
+
+ viewConfig = { r, dx, dy };
+
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
+ ctx.drawImage(bmp, dx, dy, dw, dh);
+ bmp.close();
+
+ updateBboxContainerPosition();
+ });
+ }
+ };
+ }
+
+ /**
+ * [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수
+ * - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
+ * - Class별 색상 구분 적용
+ */
+ function showDetections(meta) {
+ bboxContainerEl.innerHTML = "";
+
+ const ch = meta.ch !== undefined ? meta.ch : '-';
+ const seq = meta.seq !== undefined ? meta.seq : '-';
+ const ts = meta.ts_us !== undefined ? meta.ts_us : '-';
+ let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`];
+
+ const items = meta.items || [];
+
+ if (items.length === 0) {
+ outputLines.push("No detections");
+ } else {
+ items.forEach((it, i) => {
+ const x1 = it.x1 || 0;
+ const y1 = it.y1 || 0;
+ const x2 = it.x2 || 0;
+ const y2 = it.y2 || 0;
+
+ // 데이터에서 tag와 cls 추출
+ const tagId = it.tag !== undefined ? it.tag : -1;
+ const clsId = it.cls !== undefined ? it.cls : -1;
+ const tId = it.tid !== undefined ? it.tid : -1;
+
+ // 1. 이름 매핑 Lookup
+ let displayTagName = `Tag ${tagId}`;
+ let displayClassName = `Cls ${clsId}`;
+
+ if (LABEL_MAP[tagId]) {
+ displayTagName = LABEL_MAP[tagId].tagName;
+ if (LABEL_MAP[tagId].classes[clsId]) {
+ displayClassName = LABEL_MAP[tagId].classes[clsId];
+ }
+ }
+
+ // 2. 색상 결정
+ const boxColor = getBoxColor(tagId, clsId);
+
+ // 로그창 출력 (원본 데이터 유지)
+ outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
+
+ // 좌표 변환
+ const { r, dx, dy } = viewConfig;
+ const screenX = dx + (x1 * r);
+ const screenY = dy + (y1 * r);
+ const screenW = (x2 - x1) * r;
+ const screenH = (y2 - y1) * r;
+
+ // 박스 DOM 생성
+ const boxDiv = document.createElement('div');
+ boxDiv.className = 'bbox';
+
+ // [스타일 적용] 동적 색상 및 위치
+ boxDiv.style.left = `${screenX}px`;
+ boxDiv.style.top = `${screenY}px`;
+ boxDiv.style.width = `${screenW}px`;
+ boxDiv.style.height = `${screenH}px`;
+ boxDiv.style.borderColor = boxColor; // 테두리 색상 변경
+
+ // 라벨 생성
+ const label = document.createElement('div');
+ label.style.position = 'absolute';
+ label.style.top = '-20px'; // 박스 바로 위
+ label.style.left = '-2px'; // 테두리 두께 고려 정렬
+ label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
+ label.style.color = 'white'; // 글자는 흰색
+ label.style.fontSize = '12px';
+ label.style.fontWeight = 'bold';
+ label.style.padding = '2px 6px';
+ label.style.borderRadius = '3px';
+ label.style.whiteSpace = 'nowrap';
+ label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
+
+ // [요청 포맷 적용] Class 명 : Tag id
+ label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
+
+ boxDiv.appendChild(label);
+ bboxContainerEl.appendChild(boxDiv);
+ });
+ }
+
+ detListEl.textContent = outputLines.join("\n");
+ }
+
+ function updateBboxContainerPosition() {
+ if (!canvasEl || !bboxContainerEl) return;
+ bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
+ bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
+ bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
+ bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
+ }
+
+ // 모델 변경 이벤트
+ if (modelContainerEl) {
+ modelContainerEl.addEventListener('change', (e) => {
+ if(e.target.name === 'current-model') {
+ updateModelDisplay();
+ fetch('/set-model', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({model: e.target.value})
+ });
+ }
+ });
+ }
+
+ function updateModelDisplay() {
+ if(!modelContainerEl || !modelDisplayEl) return;
+ const checked = modelContainerEl.querySelector('input:checked');
+ if(checked) {
+ const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
+ modelDisplayEl.textContent = label ? label.textContent : checked.value;
+ }
+ }
+
+ const detPanelToggle = document.getElementById('det-panel-toggle');
+ const detWrap = document.getElementById('det-wrap');
+
+ if (detPanelToggle && detWrap) {
+ detPanelToggle.addEventListener('change', (e) => {
+ if (e.target.checked) {
+ // 체크됨 -> 패널 보이기 (기존 flex 속성 복구)
+ detWrap.classList.remove('collapsed-panel');
+ } else {
+ // 체크 해제 -> 패널 숨기기
+ detWrap.classList.add('collapsed-panel');
+ }
+
+ // 패널 크기가 변하므로 캔버스 내 BBox 위치 재계산 필요
+ // 약간의 지연 후 실행 (Flexbox 애니메이션 등이 없으므로 즉시 해도 되지만 안전하게)
+ setTimeout(() => {
+ if(typeof updateBboxContainerPosition === 'function') {
+ updateBboxContainerPosition();
+ }
+ }, 50);
+ });
+ }
+
+ // 초기 실행
+ updateModelDisplay();
+ if (canvasEl) connect();
+ window.addEventListener('resize', updateBboxContainerPosition);
+ loadPoiList();
+ loadModelList();
+});
\ No newline at end of file
diff --git a/public/_dashboard.html b/public/_dashboard.html
new file mode 100644
index 0000000..d2f3bb1
--- /dev/null
+++ b/public/_dashboard.html
@@ -0,0 +1,189 @@
+
+
+
+
+
+ Dashboard - AI Drone System
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 선택된 파일 없음
+
+
+
+
+
+
+ | 번호 |
+ AI Model 역할 |
+ 파일명 |
+ 현재 버전 |
+ 삭제 |
+
+
+
+
+ | 1 |
+ 객체 탐지/ 분류 |
+ - |
+ v1.0 |
+
+
+ |
+
+
+ | 2 |
+ 화재(불꽃, 연기) 감지 |
+ - |
+ v1.0 |
+
+
+ |
+
+
+ | 3 |
+ 이상행동(쓰러짐, 폭행) 감지 |
+ - |
+ v1.0 |
+
+
+ |
+
+
+ | 4 |
+ 얼굴/ 인상착의 인식 |
+ - |
+ - |
+
+
+ |
+
+
+ | 5 |
+ 차량 번호판/ 차종 인식 |
+ - |
+ - |
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+ | 번호 |
+ 관심인물명 |
+ 미리 보기 |
+ 파일 등록 |
+
+
+
+
+
+
+ 등록된 관심 인물이 없습니다
+
+
+
+
+
+
+
관심인물 등록
+
+
+
+
+
+
+
+
+
+
+
+
×
+
![]()
+
+
+
+
+
\ No newline at end of file
diff --git a/public/_index.html b/public/_index.html
new file mode 100644
index 0000000..6a0bbb7
--- /dev/null
+++ b/public/_index.html
@@ -0,0 +1,79 @@
+
+
+
+
+
+ AI Drone System - Login
+
+
+
+
+ AI Mission Camera Console for Drones
+
+
+
Login
+
Sign In to your account
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/_style.css b/public/_style.css
new file mode 100644
index 0000000..a7b0d7d
--- /dev/null
+++ b/public/_style.css
@@ -0,0 +1,431 @@
+/* style.css */
+
+/* 기본 및 공통 스타일 */
+body {
+ font-family: Arial, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: #f0f2f5;
+ color: #333;
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+/* 버튼 기본 스타일 reset 및 공통 */
+button {
+ cursor: pointer;
+ border-radius: 4px;
+ border: 1px solid #555;
+ background-color: #3a3a3a;
+ color: #e0e0e0;
+ padding: 5px 10px;
+}
+
+/* 로그인 페이지 등 배경 스타일 */
+.login-page {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100vh;
+ /* 배경 이미지 설정 (전체 화면 꽉 채우기) */
+ background: url('drone_background.png') no-repeat center center fixed;
+ background-size: cover;
+ position: relative; /* page-title 절대 위치의 기준점 */
+}
+
+/* [수정됨] 로그인 화면 메인 타이틀 스타일 */
+.page-title {
+ position: absolute;
+ top: 60px; /* 상단 여백 */
+ left: 60px; /* 좌측 여백 */
+ color: #ffffff; /* 흰색 텍스트 */
+ font-size: 3rem; /* 크기 2배 (약 64px ~ 70px) */
+ font-weight: 600;
+ margin: 0;
+ z-index: 10;
+ /* 배경과 구분을 위한 검은 그림자 */
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
+}
+
+.login-container {
+ background: rgba(255, 255, 255, 0.95); /* 배경이 비치지 않도록 불투명도 조정 */
+ padding: 2rem 3rem;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* 그림자 조금 더 진하게 */
+ width: 400px;
+ box-sizing: border-box;
+ z-index: 20;
+}
+.login-container h1 {
+ font-size: 2.2rem;
+ color: #333;
+ margin-bottom: 0.5rem;
+ font-weight: bold;
+ text-align: center; /* 로그인 박스 내부 타이틀 중앙 정렬 */
+}
+.login-subtext {
+ font-size: 1rem;
+ color: #666;
+ margin: 0 0 2.5rem 0;
+ text-align: center; /* 서브 텍스트 중앙 정렬 */
+}
+.input-group {
+ margin-bottom: 1rem;
+ position: relative;
+ text-align: left;
+}
+.input-group input {
+ width: 100%;
+ padding: 1rem 1rem 1rem 3rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ box-sizing: border-box;
+ background-color: #fff;
+ color: #333;
+ font-size: 1rem;
+}
+.input-group .icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ font-size: 1.2rem;
+}
+
+/* [수정됨] 로그인 버튼 스타일 (중앙 정렬 추가) */
+.login-button {
+ width: auto;
+ min-width: 150px;
+ padding: 0.8rem 2.5rem;
+ background-color: #5a67d8;
+ color: white;
+ border: none;
+ font-size: 1rem;
+ font-weight: bold;
+
+ /* 중앙 정렬을 위한 설정 */
+ display: block;
+ margin: 1.5rem auto 0 auto;
+}
+
+.login-button:hover { background-color: #434190; }
+.footer-bar {
+ position: absolute; bottom: 0; left: 0; width: 100%; height: 50px;
+ background-color: #ffffff; opacity: 0.7; z-index: 100;
+ display: flex; justify-content: center; align-items: center; gap: 20px;
+}
+
+
+/* 대시보드 헤더 */
+header {
+ background-color: #333;
+ padding: 0 1rem;
+ flex-shrink: 0;
+}
+.tabs { display: flex; justify-content: space-between; align-items: center; }
+.tab-button {
+ background: none; border: none; color: #aaa;
+ padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold;
+}
+.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; }
+.logout-button {
+ background-color: #d9534f; color: white; border: none;
+ padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold;
+}
+
+/* 메인 컨텐츠 (다크 테마) */
+main {
+ padding: 1rem;
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ background-color: #1a1a1a;
+ color: #e0e0e0;
+}
+
+.tab-content { display: none; }
+.tab-content.active {
+ display: flex; flex-direction: column; flex-grow: 1; min-height: 0;
+}
+
+/* Video 스타일 */
+.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; }
+.video-player {
+ width: 100%; flex-grow: 1; min-height: 0;
+ background-color: #111; color: #eee;
+ display: flex; flex-direction: row; gap: 10px;
+ padding: 10px; box-sizing: border-box; border-radius: 4px; position: relative;
+}
+#video-wrap { flex: 2; display: flex; flex-direction: column; gap: 4px; position: relative; }
+#frame { flex: 1; min-height: 0; max-width: 100%; background: #000; border: 1px solid #333; z-index: 1; }
+#det-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; z-index: 1; }
+#det-list { flex: 1; font-size: 11px; background: #000; border: 1px solid #333; padding: 6px; overflow-y: auto; white-space: pre; }
+#bbox-container { position: absolute; pointer-events: none; z-index: 5; top: 0; left: 0; }
+.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
+
+/* Segmented Control (모델 선택) */
+.segmented-control-container {
+ position: absolute; top: 60px; left: 20px; z-index: 10;
+ display: flex; flex-wrap: wrap; gap: 4px;
+ background-color: rgba(50, 50, 50, 0.7); padding: 4px; border-radius: 6px;
+}
+.segmented-control-container input[type="radio"] { display: none; }
+.segmented-control-button {
+ display: inline-block; padding: 6px 10px; font-size: 0.85rem;
+ color: #eee; background-color: #555; border: 1px solid #777; border-radius: 4px;
+ cursor: pointer; user-select: none;
+}
+.segmented-control-container input[type="radio"]:checked + .segmented-control-button {
+ background-color: #5a67d8; color: white; border-color: #434190; font-weight: bold;
+}
+
+/* Video Overlays */
+.video-overlay {
+ position: absolute; z-index: 10;
+ background-color: rgba(0, 0, 0, 0.5); color: #ffffff;
+ padding: 4px 8px; border-radius: 4px; font-weight: bold; pointer-events: none;
+ text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
+}
+.video-overlay.top-right { top: 100px; right: 10px; font-size: 60px; }
+.video-overlay.bottom-right { bottom: 70px; right: 10px; font-size: 28px; color: #FFEE00; }
+
+
+/* ========================================================== */
+/* [설정 탭 - AI 모델 & 관심 인물 관리 스타일] */
+/* ========================================================== */
+
+.toolbar {
+ display: flex; align-items: center; gap: 10px; margin-bottom: 1rem;
+}
+.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; }
+.right-actions { margin-left: auto; display: flex; gap: 10px; }
+
+/* 공통 테이블 스타일 */
+.model-table {
+ width: 100%; border-collapse: collapse; background: #2a2a2a;
+}
+.model-table th, .model-table td {
+ border: 1px solid #444; padding: 0.8rem 1rem; vertical-align: middle;
+}
+.model-table th { background-color: #333; text-align: center; }
+.model-table td:first-child { text-align: center; } /* 번호 */
+.model-table td { text-align: center; } /* 기본 중앙 정렬 */
+
+/* 파일명 표시 input */
+#file-name-display {
+ font-size: 0.9rem; color: #ccc; background-color: #2a2a2a;
+ border: 1px solid #444; padding: 5px 10px; border-radius: 4px;
+ min-width: 300px; text-align: left; font-style: italic;
+}
+.hidden-file-input { display: none; }
+.hidden { display: none !important; }
+
+/* 버튼 스타일 */
+.btn-browse {
+ cursor: pointer; border-radius: 4px; border: 1px solid #666;
+ padding: 5px 10px; font-size: 14px; background-color: #4f4f4f; color: #e0e0e0;
+}
+.btn-browse:hover { background-color: #5a5a5a; }
+
+.btn-action { background-color: #4CAF50; color: white; border: none; }
+.btn-delete { background-color: #f44336; color: white; border: none; }
+.refresh-button { margin-left: auto; }
+
+/* 흰색 배경 버튼 (UI 디자인 반영) */
+.btn-white {
+ background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold;
+}
+.btn-white:hover { background-color: #f0f0f0; }
+
+/* 구분선 */
+.divider {
+ height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none;
+}
+
+/* ========================================================== */
+/* [신규] 관심 인물(POI) 관리 전용 스타일 */
+/* ========================================================== */
+
+/* 빈 목록 메시지 */
+.empty-message {
+ text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem;
+}
+
+/* 미리보기 컨테이너 (세로 정렬) */
+.preview-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+ justify-content: center;
+}
+
+/* 개별 이미지 아이템 (이미지 + 삭제버튼) */
+.preview-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ background: rgba(255, 255, 255, 0.05);
+ padding: 4px;
+ border-radius: 4px;
+}
+
+/* 미리보기 이미지 크기 */
+.poi-preview {
+ width: 50px; height: 50px; object-fit: cover;
+ border-radius: 4px; border: 1px solid #555; display: block; margin: 0 auto;
+ cursor: pointer; /* 클릭 가능 표시 */
+ transition: 0.3s;
+}
+.poi-preview:hover { opacity: 0.7; }
+
+/* 삭제 버튼 공통 */
+.btn-delete-small {
+ background-color: #fff; border: 1px solid #ccc; color: #333;
+ padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px;
+ border-radius: 2px;
+}
+.btn-delete-small:hover { background-color: #eee; }
+
+/* 모달 관련 스타일 (기존 유지) */
+.modal {
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex; justify-content: center; align-items: center; z-index: 2000;
+}
+.modal-content {
+ background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333;
+}
+.modal-content h3 { margin-top: 0; margin-bottom: 2rem; text-align: left; font-size: 1.2rem; font-weight: bold; }
+.modal-body { margin-bottom: 2rem; }
+.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; text-align: center; box-sizing: border-box; }
+.modal-actions { display: flex; justify-content: center; gap: 20px; }
+.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; }
+.btn-modal-confirm { background-color: #fff; color: #333; border: 1px solid #999; }
+.btn-modal-cancel { background-color: #fff; color: #333; border: 1px solid #999; }
+.btn-modal-confirm:hover, .btn-modal-cancel:hover { background-color: #eee; }
+
+/* ========================================================== */
+/* [신규] 이미지 뷰어 모달 스타일 */
+/* ========================================================== */
+
+/* 뷰어 모달 이미지 */
+.modal-image {
+ margin: auto;
+ display: block;
+ max-width: 90%;
+ max-height: 90%;
+ border-radius: 5px;
+ animation-name: zoom;
+ animation-duration: 0.4s;
+}
+
+/* 확대 애니메이션 */
+@keyframes zoom {
+ from {transform:scale(0)}
+ to {transform:scale(1)}
+}
+
+/* 닫기 버튼 (X) */
+.close-viewer {
+ position: absolute;
+ top: 20px;
+ right: 35px;
+ color: #f1f1f1;
+ font-size: 40px;
+ font-weight: bold;
+ transition: 0.3s;
+ cursor: pointer;
+ z-index: 2001;
+}
+
+.close-viewer:hover,
+.close-viewer:focus {
+ color: #bbb;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+#info {
+ display: flex;
+ align-items: center;
+ gap: 15px;
+ background-color: rgba(0, 0, 0, 0.6); /* 가독성을 위한 반투명 배경 */
+ padding: 8px 12px;
+ border-radius: 4px;
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 20; /* 오버레이보다 위 */
+ color: #fff;
+ font-size: 14px;
+}
+
+/* [추가됨] 토글 스위치 컨테이너 */
+.panel-toggle-container {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-left: 10px;
+ border-left: 1px solid #555;
+ padding-left: 15px;
+}
+
+.toggle-text {
+ font-size: 0.85rem;
+ color: #ddd;
+ font-weight: bold;
+}
+
+/* [추가됨] 스위치(Checkbox) 디자인 */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 34px;
+ height: 20px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background-color: #ccc;
+ transition: .4s;
+ border-radius: 34px;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 14px;
+ width: 14px;
+ left: 3px;
+ bottom: 3px;
+ background-color: white;
+ transition: .4s;
+ border-radius: 50%;
+}
+
+/* 체크되었을 때 색상 (기존 버튼 색상과 통일) */
+input:checked + .slider {
+ background-color: #5a67d8;
+}
+
+input:checked + .slider:before {
+ transform: translateX(14px);
+}
+
+/* [추가됨] 패널 숨김 처리용 클래스 */
+/* !important를 사용하여 flex 속성을 덮어씌웁니다 */
+.collapsed-panel {
+ display: none !important;
+}
+/*2025.11.25 16:16*/
\ No newline at end of file
diff --git a/public/app.js b/public/app.js
index b20be28..517fa34 100644
--- a/public/app.js
+++ b/public/app.js
@@ -9,7 +9,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
if (confirm('정말로 로그아웃 하시겠습니까?')) {
- // 로그인 페이지(루트)로 이동
window.location.href = '/';
}
});
@@ -29,6 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
tabModels.classList.remove('active');
contentVideo.classList.add('active');
contentModels.classList.remove('active');
+ updateBboxContainerPosition();
});
tabModels.addEventListener('click', () => {
@@ -42,7 +42,284 @@ document.addEventListener('DOMContentLoaded', () => {
}
// =================================================
- // 2. AI 모델 관리 로직
+ // [신규] Mission UI 로직
+ // =================================================
+ const MODEL_DEFINITIONS = {
+ 'OBJDET': { name: "객체 탐지", classes: ["person", "car", "van", "truck", "bus", "motor"] },
+ 'FIRE': { name: "화재 감지", classes: ["flame", "smoke"] },
+ 'CROWD': { name: "군중 위험", classes: ["person", "crowd"] },
+ 'FACEATTR': { name: "얼굴 인식", classes: ["face"] },
+ 'ABNORM': { name: "이상 행동", classes: ["fall", "fight"] },
+ 'LPR': { name: "차량 인식", classes: ["plate"] },
+ 'VIPTRACK': { name: "관심 인물", classes: ["person"] }
+ };
+
+ let currentModelCode = 'OBJDET';
+ let activeClassFilters = new Set();
+
+ const navItems = document.querySelectorAll('.nav-item');
+ navItems.forEach(item => {
+ item.addEventListener('click', () => {
+ navItems.forEach(nav => nav.classList.remove('active'));
+ item.classList.add('active');
+
+ const modelCode = item.dataset.model;
+ if(modelCode) {
+ currentModelCode = modelCode;
+ changeModel(modelCode);
+ updateFilterBar(modelCode);
+ }
+ });
+ });
+
+ function changeModel(modelCode) {
+ fetch('/set-model', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({model: modelCode})
+ }).then(r => console.log(`Model changed to ${modelCode}`))
+ .catch(e => console.error(e));
+ }
+
+ const filterBar = document.getElementById('filter-bar');
+
+ function updateFilterBar(modelCode) {
+ if(!filterBar) return;
+ filterBar.innerHTML = '';
+
+ const def = MODEL_DEFINITIONS[modelCode];
+ if(!def) return;
+
+ const allLabel = document.createElement('label');
+ allLabel.className = 'filter-check';
+ const allInput = document.createElement('input');
+ allInput.type = 'checkbox';
+ allInput.checked = true; // 기본적으로 All은 체크됨
+ allLabel.appendChild(allInput);
+ allLabel.append(' all');
+
+ const sep = document.createElement('span');
+ sep.className = 'v-line';
+ sep.innerText = '|';
+
+ filterBar.appendChild(allLabel);
+ filterBar.appendChild(sep);
+
+ const classInputs = [];
+
+ def.classes.forEach(cls => {
+ const label = document.createElement('label');
+ label.className = 'filter-check';
+ const input = document.createElement('input');
+ input.type = 'checkbox';
+ input.checked = true; // All이 체크되어 있으므로 기본적으로 모두 체크
+ input.dataset.cls = cls;
+ label.appendChild(input);
+ label.append(` ${cls}`);
+ filterBar.appendChild(label);
+ classInputs.push(input);
+
+ // 개별 체크박스 변경 시 로직
+ input.addEventListener('change', () => {
+ // 하나라도 꺼지면 All 체크 해제, 모두 켜지면 All 체크
+ const allChecked = classInputs.every(i => i.checked);
+ allInput.checked = allChecked;
+ updateActiveFilters(classInputs);
+ });
+ });
+
+ // All 체크박스 변경 시 로직
+ allInput.addEventListener('change', () => {
+ const isChecked = allInput.checked;
+ classInputs.forEach(inp => inp.checked = isChecked);
+ updateActiveFilters(classInputs);
+ });
+
+ // 초기 필터 상태 반영
+ updateActiveFilters(classInputs);
+ }
+
+ function updateActiveFilters(classInputs) {
+ activeClassFilters.clear();
+ // 체크된 항목들만 Set에 추가
+ classInputs.forEach(inp => {
+ if(inp.checked) activeClassFilters.add(inp.dataset.cls);
+ });
+ }
+
+ updateFilterBar('OBJDET');
+
+ // =================================================
+ // 2. 비디오 & 웹소켓 & Summary 패널
+ // =================================================
+ const LABEL_MAP = {
+ 1: { tagName: "객체 탐지", classes: { 0: "person", 1: "car", 2: "van", 3: "truck", 4: "bus", 5: "motor" } },
+ 2: { tagName: "화재 인식", classes: { 0: "flame", 1: "smoke" } },
+ 3: { tagName: "얼굴 인식", classes: { 0: "face" } },
+ 4: { tagName: "차량번호", classes: { 0: "plate" } }
+ };
+
+ function getBoxColor(clsName) {
+ const colorMap = {
+ 'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500',
+ 'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC'
+ };
+ return colorMap[clsName] || '#FFFFFF';
+ }
+
+ const uri = "ws://10.10.11.246:8765";
+ const canvasEl = document.getElementById("frame");
+ const bboxContainerEl = document.getElementById("bbox-container");
+ const fpsDisplayEl = document.getElementById("fps-display");
+ const resolutionEl = document.getElementById("resolution-display");
+ const connMsgEl = document.getElementById("connection-msg");
+ const summaryListEl = document.getElementById("summary-list");
+ let ctx = null;
+
+ if (canvasEl) ctx = canvasEl.getContext('2d');
+ let frameCount = 0;
+ let lastFpsCheckTime = performance.now();
+ let lastFrameMeta = null;
+ let viewConfig = { r: 1, dx: 0, dy: 0 };
+
+ function connect() {
+ const ws = new WebSocket(uri);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => { if(connMsgEl) connMsgEl.textContent = "영상 수신 중"; };
+ ws.onclose = () => {
+ if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)";
+ setTimeout(connect, 2000);
+ };
+
+ ws.onmessage = (event) => {
+ const data = event.data;
+ if (typeof data === "string") {
+ try {
+ const meta = JSON.parse(data);
+ if (meta.type === "frame") {
+ lastFrameMeta = meta;
+ showDetections(meta);
+ }
+ } catch(e){ console.error(e); }
+ } else if (data instanceof ArrayBuffer && lastFrameMeta) {
+ frameCount++;
+ const now = performance.now();
+ if (now - lastFpsCheckTime >= 1000) {
+ if(fpsDisplayEl) fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
+ frameCount = 0;
+ lastFpsCheckTime = now;
+ }
+ const blob = new Blob([data], {type: "image/jpeg"});
+ createImageBitmap(blob).then(bmp => {
+ if (resolutionEl) {
+ resolutionEl.textContent = `${bmp.width}x${bmp.height}`;
+ }
+
+ canvasEl.width = canvasEl.clientWidth;
+ canvasEl.height = canvasEl.clientHeight;
+ const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
+ const dw = bmp.width * r;
+ const dh = bmp.height * r;
+ const dx = (canvasEl.width - dw) / 2;
+ const dy = (canvasEl.height - dh) / 2;
+ viewConfig = { r, dx, dy };
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
+ ctx.drawImage(bmp, dx, dy, dw, dh);
+ bmp.close();
+ updateBboxContainerPosition();
+ });
+ }
+ };
+ }
+
+ function showDetections(meta) {
+ bboxContainerEl.innerHTML = "";
+ const items = meta.items || [];
+ const currentCounts = {};
+
+ items.forEach((it) => {
+ const tagId = it.tag !== undefined ? it.tag : -1;
+ const clsId = it.cls !== undefined ? it.cls : -1;
+
+ let displayClassName = `Cls ${clsId}`;
+ if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) {
+ displayClassName = LABEL_MAP[tagId].classes[clsId];
+ }
+
+ // [수정] 필터링 로직 강화: Set에 없으면 절대 그리지 않음 (All 체크 로직과 연동)
+ if (!activeClassFilters.has(displayClassName)) {
+ return;
+ }
+
+ if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0;
+ currentCounts[displayClassName]++;
+
+ const x1 = it.x1 || 0; const y1 = it.y1 || 0;
+ const x2 = it.x2 || 0; const y2 = it.y2 || 0;
+ const boxColor = getBoxColor(displayClassName);
+
+ const { r, dx, dy } = viewConfig;
+ const screenX = dx + (x1 * r);
+ const screenY = dy + (y1 * r);
+ const screenW = (x2 - x1) * r;
+ const screenH = (y2 - y1) * r;
+
+ const boxDiv = document.createElement('div');
+ boxDiv.className = 'bbox';
+ boxDiv.style.left = `${screenX}px`;
+ boxDiv.style.top = `${screenY}px`;
+ boxDiv.style.width = `${screenW}px`;
+ boxDiv.style.height = `${screenH}px`;
+ boxDiv.style.borderColor = boxColor;
+
+ const label = document.createElement('div');
+ label.style.position = 'absolute';
+ label.style.top = '-20px';
+ label.style.left = '-2px';
+ label.style.backgroundColor = boxColor;
+ label.style.color = '#000';
+ label.style.fontSize = '11px';
+ label.style.fontWeight = 'bold';
+ label.style.padding = '1px 4px';
+ label.textContent = displayClassName;
+
+ boxDiv.appendChild(label);
+ bboxContainerEl.appendChild(boxDiv);
+ });
+
+ updateSummaryPanel(currentCounts);
+ }
+
+ function updateSummaryPanel(counts) {
+ if(!summaryListEl) return;
+ summaryListEl.innerHTML = '';
+ const keys = Object.keys(counts);
+ if (keys.length === 0) {
+ summaryListEl.innerHTML = 'No detection
';
+ return;
+ }
+ keys.forEach(key => {
+ const row = document.createElement('div');
+ row.className = 'summary-row';
+ row.innerHTML = `${key}${counts[key]}`;
+ 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');
@@ -110,70 +387,44 @@ document.addEventListener('DOMContentLoaded', () => {
// =================================================
- // 3. 관심 인물(POI) 관리 로직
+ // 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');
- // POI 목록 로드
function loadPoiList() {
if (!poiTableBody) return;
-
fetch('/poi/list')
.then(res => res.json())
.then(list => {
poiTableBody.innerHTML = '';
-
if (!list || list.length === 0) {
poiEmptyMsg.classList.remove('hidden');
poiTableBody.parentElement.classList.add('hidden');
} else {
poiEmptyMsg.classList.add('hidden');
poiTableBody.parentElement.classList.remove('hidden');
-
list.forEach((item, index) => {
const tr = document.createElement('tr');
-
let previewHtml = '-';
if (item.images && item.images.length > 0) {
const imagesHtml = item.images.map(imgName => {
const imgSrc = `/poi-images/${item.name}/${imgName}`;
- return `
-
-

-
-
- `;
+ return `
`;
}).join('');
previewHtml = `${imagesHtml}
`;
}
-
const fileInputId = `poi-file-${item.name}`;
-
- tr.innerHTML = `
- ${index + 1} |
-
- ${item.name}
-
- |
- ${previewHtml} |
-
-
-
- |
- `;
+ tr.innerHTML = `${index + 1} | ${item.name} | ${previewHtml} | | `;
poiTableBody.appendChild(tr);
});
}
@@ -181,407 +432,44 @@ document.addEventListener('DOMContentLoaded', () => {
.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');
- });
+ closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); });
}
window.addEventListener('click', (e) => {
- if (e.target === imageViewerModal) {
- imageViewerModal.classList.add('hidden');
- }
+ if (e.target === imageViewerModal) { imageViewerModal.classList.add('hidden'); }
});
-
-
- // [전역 함수] 인물 전체 삭제
window.deletePoi = function(name) {
- if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return;
-
- fetch('/poi/delete', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name })
- })
- .then(res => res.json())
- .then(data => {
- if (data.status === 'success') loadPoiList();
- else alert('삭제 실패');
- });
+ 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();
- else alert('이미지 삭제 실패');
- });
+ 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++) {
- const file = input.files[i];
- const ext = file.name.split('.').pop().toLowerCase();
-
- if (!['jpg', 'jpeg', 'png'].includes(ext)) {
- alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
- input.value = '';
- return;
- }
- formData.append('poiFile', file);
- }
-
- fetch('/poi/upload-image', { method: 'POST', body: formData })
- .then(res => res.json())
- .then(data => {
- if (data.status === 'success') {
- alert('이미지 등록 완료');
- loadPoiList();
- } else {
- alert('업로드 실패: ' + (data.message || '오류'));
- }
- })
- .catch(() => alert('오류 발생'));
-
+ 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 (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); }
+ if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); }
if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim();
- if (!name) return alert('이름을 입력하세요.');
- const regex = /^[a-zA-Z0-9]+$/;
- if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
-
- fetch('/poi/register', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ name })
- })
- .then(res => res.json())
- .then(data => {
- if (data.status === 'success') {
- poiModal.classList.add('hidden');
- loadPoiList();
- } else {
- alert(data.message || '등록 실패');
- }
- });
- });
- }
-
- // =================================================
- // 4. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
- // =================================================
-
- // [설정] Tag/Class 매핑 테이블
- const LABEL_MAP = {
- 1: {
- tagName: "객체 탐지/ 분류",
- classes: {
- 0: "person",
- 1: "car",
- 2: "van",
- 3: "truck",
- 4: "bus",
- 5: "motor"
- }
- },
- 2: {
- tagName: "화재 인식",
- classes: {
- 0: "flame",
- 1: "smoke"
- }
- },
- 3: {
- tagName: "얼굴 인식",
- classes: {
- 0: "face"
- }
- },
- 4: {
- tagName: "차량번호판 및 차종 인식",
- classes: {
- 0: "License plate"
- }
- }
- };
-
- // [설정] 박스 색상 생성 함수
- function getBoxColor(tag, cls) {
- // Tag 2: 화재 관련 (긴급한 색상)
- if (tag === 2) {
- if (cls === 0) return '#FF0000'; // Flame: 빨강
- if (cls === 1) return '#808080'; // Smoke: 회색
- }
-
- // Tag 1: 객체 탐지 (다양한 색상)
- if (tag === 1) {
- const colors = [
- '#00FF00', // Person: 라임 그린
- '#00FFFF', // Car: 시안
- '#FFA500', // Van: 오렌지
- '#FF69B4', // Truck: 핫핑크
- '#9370DB', // Bus: 미디엄 퍼플
- '#FFD700' // Motor: 골드
- ];
- return colors[cls % colors.length] || '#FFFFFF';
- }
-
- // Tag 3: 얼굴
- if (tag === 3) return '#1E90FF'; // 도저 블루
-
- // Tag 4: 번호판
- if (tag === 4) return '#32CD32'; // 라임 그린
-
- // 기타 기본값
- return '#FF0000';
- }
-
- const uri = "ws://10.10.11.246:8765";
- const canvasEl = document.getElementById("frame");
- const statusEl = document.getElementById("status");
- const detListEl = document.getElementById("det-list");
- const bboxContainerEl = document.getElementById("bbox-container");
- const modelContainerEl = document.getElementById("current-model-container");
- const modelDisplayEl = document.getElementById("current-model-display");
- const fpsDisplayEl = document.getElementById("fps-display");
- let ctx = null;
-
- if (canvasEl) ctx = canvasEl.getContext('2d');
- let frameCount = 0;
- let lastFpsCheckTime = performance.now();
- let lastFrameMeta = null;
-
- let viewConfig = { r: 1, dx: 0, dy: 0 };
-
- function connect() {
- const ws = new WebSocket(uri);
- ws.binaryType = "arraybuffer";
-
- ws.onopen = () => {
- statusEl.textContent = "연결됨";
- statusEl.className = "status";
- };
-
- ws.onclose = () => {
- statusEl.textContent = "연결 종료. 재접속...";
- statusEl.className = "status err";
- setTimeout(connect, 2000);
- };
-
- ws.onmessage = (event) => {
- const data = event.data;
-
- // 1. 텍스트 데이터(JSON) 처리
- if (typeof data === "string") {
- console.log("-----" + data); // 필요시 주석 해제
- try {
- const meta = JSON.parse(data);
- if (meta.type === "frame") {
- lastFrameMeta = meta;
- document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
- showDetections(meta);
- }
- } catch(e){ console.error(e); }
-
- // 2. 바이너리 데이터(영상 프레임) 처리
- } else if (data instanceof ArrayBuffer && lastFrameMeta) {
- frameCount++;
- const now = performance.now();
- if (now - lastFpsCheckTime >= 1000) {
- fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
- frameCount = 0;
- lastFpsCheckTime = now;
- }
-
- const blob = new Blob([data], {type: "image/jpeg"});
- createImageBitmap(blob).then(bmp => {
- canvasEl.width = canvasEl.clientWidth;
- canvasEl.height = canvasEl.clientHeight;
-
- const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
- const dw = bmp.width * r;
- const dh = bmp.height * r;
- const dx = (canvasEl.width - dw) / 2;
- const dy = (canvasEl.height - dh) / 2;
-
- viewConfig = { r, dx, dy };
-
- ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
- ctx.drawImage(bmp, dx, dy, dw, dh);
- bmp.close();
-
- updateBboxContainerPosition();
- });
- }
- };
- }
-
- /**
- * [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수
- * - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
- * - Class별 색상 구분 적용
- */
- function showDetections(meta) {
- bboxContainerEl.innerHTML = "";
-
- const ch = meta.ch !== undefined ? meta.ch : '-';
- const seq = meta.seq !== undefined ? meta.seq : '-';
- const ts = meta.ts_us !== undefined ? meta.ts_us : '-';
- let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`];
-
- const items = meta.items || [];
-
- if (items.length === 0) {
- outputLines.push("No detections");
- } else {
- items.forEach((it, i) => {
- const x1 = it.x1 || 0;
- const y1 = it.y1 || 0;
- const x2 = it.x2 || 0;
- const y2 = it.y2 || 0;
-
- // 데이터에서 tag와 cls 추출
- const tagId = it.tag !== undefined ? it.tag : -1;
- const clsId = it.cls !== undefined ? it.cls : -1;
- const tId = it.tid !== undefined ? it.tid : -1;
-
- // 1. 이름 매핑 Lookup
- let displayTagName = `Tag ${tagId}`;
- let displayClassName = `Cls ${clsId}`;
-
- if (LABEL_MAP[tagId]) {
- displayTagName = LABEL_MAP[tagId].tagName;
- if (LABEL_MAP[tagId].classes[clsId]) {
- displayClassName = LABEL_MAP[tagId].classes[clsId];
- }
- }
-
- // 2. 색상 결정
- const boxColor = getBoxColor(tagId, clsId);
-
- // 로그창 출력 (원본 데이터 유지)
- outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
-
- // 좌표 변환
- const { r, dx, dy } = viewConfig;
- const screenX = dx + (x1 * r);
- const screenY = dy + (y1 * r);
- const screenW = (x2 - x1) * r;
- const screenH = (y2 - y1) * r;
-
- // 박스 DOM 생성
- const boxDiv = document.createElement('div');
- boxDiv.className = 'bbox';
-
- // [스타일 적용] 동적 색상 및 위치
- boxDiv.style.left = `${screenX}px`;
- boxDiv.style.top = `${screenY}px`;
- boxDiv.style.width = `${screenW}px`;
- boxDiv.style.height = `${screenH}px`;
- boxDiv.style.borderColor = boxColor; // 테두리 색상 변경
-
- // 라벨 생성
- const label = document.createElement('div');
- label.style.position = 'absolute';
- label.style.top = '-20px'; // 박스 바로 위
- label.style.left = '-2px'; // 테두리 두께 고려 정렬
- label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
- label.style.color = 'white'; // 글자는 흰색
- label.style.fontSize = '12px';
- label.style.fontWeight = 'bold';
- label.style.padding = '2px 6px';
- label.style.borderRadius = '3px';
- label.style.whiteSpace = 'nowrap';
- label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
-
- // [요청 포맷 적용] Class 명 : Tag id
- label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
-
- boxDiv.appendChild(label);
- bboxContainerEl.appendChild(boxDiv);
- });
- }
-
- detListEl.textContent = outputLines.join("\n");
- }
-
- function updateBboxContainerPosition() {
- if (!canvasEl || !bboxContainerEl) return;
- bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
- bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
- bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
- bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
- }
-
- // 모델 변경 이벤트
- if (modelContainerEl) {
- modelContainerEl.addEventListener('change', (e) => {
- if(e.target.name === 'current-model') {
- updateModelDisplay();
- fetch('/set-model', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({model: e.target.value})
- });
- }
+ 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('실패'); } });
});
}
- 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();
- 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 c1a49ff..44f8bf2 100644
--- a/public/dashboard.html
+++ b/public/dashboard.html
@@ -9,57 +9,103 @@