From a3ccc7bb920ef84acc9a60d266e2de0a8acbc648 Mon Sep 17 00:00:00 2001 From: dongjin kim Date: Thu, 4 Dec 2025 15:30:42 +0900 Subject: [PATCH] Renewal design. --- _server.js | 306 ++++++++++++++++++ public/_app.js | 610 +++++++++++++++++++++++++++++++++++ public/_dashboard.html | 189 +++++++++++ public/_index.html | 79 +++++ public/_style.css | 431 +++++++++++++++++++++++++ public/app.js | 700 +++++++++++++++++------------------------ public/dashboard.html | 165 +++++++--- public/style.css | 436 +++++++++++++------------ 8 files changed, 2235 insertions(+), 681 deletions(-) create mode 100644 _server.js create mode 100644 public/_app.js create mode 100644 public/_dashboard.html create mode 100644 public/_index.html create mode 100644 public/_style.css 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 + + + +
+ +
+ +
+
+
+
+
+
+ 연결 시도 중... + +
+ + Detections +
+
+ + + +
+
+
+
Detections
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+ + + + 선택된 파일 없음 + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호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

+ + +
+
+ 👤 + +
+
+ 🔒 + +
+ +
+
+ + + + + + \ 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 @@
-
-
-
-
-
- 연결 시도 중... - +
+ +
+ -
- - - - - - - - - - - - - - +
+
+ + | + +
- - +
+
+ +
+
- - +
+ +
@@ -84,19 +130,39 @@ - 1객체 탐지/ 분류-v1.0 + 1 + 객체 탐지/ 분류 + - + v1.0 + - 2화재(불꽃, 연기) 감지-v1.0 + 2 + 화재(불꽃, 연기) 감지 + - + v1.0 + - 3이상행동(쓰러짐, 폭행) 감지-v1.0 + 3 + 이상행동(쓰러짐, 폭행) 감지 + - + v1.0 + - 4얼굴/ 인상착의 인식-- + 4 + 얼굴/ 인상착의 인식 + - + - + - 5차량 번호판/ 차종 인식-- + 5 + 차량 번호판/ 차종 인식 + - + - + @@ -113,15 +179,14 @@ - - - - - - + + + + + + - - +
번호관심인물명미리 보기파일 등록
번호관심인물명미리 보기파일 등록