From 962fb2c3c3be5b4be7d8a9e0dfc71157547afa62 Mon Sep 17 00:00:00 2001 From: dongjin kim Date: Thu, 11 Dec 2025 17:07:28 +0900 Subject: [PATCH] =?UTF-8?q?=EC=96=BC=EA=B5=B4=EC=9D=B8=EC=8B=9D,=20?= =?UTF-8?q?=EC=9D=B4=EC=83=81=ED=96=89=EB=8F=99=20=EA=B0=90=EC=A7=80=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=AA=A9=EB=A1=9D=20=EC=9E=91=EC=97=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- _server.js | 306 ----------------------------------------------- public/app.js | 270 ++++++++++++++++++++++++++++++----------- public/style.css | 53 ++++++++ server.js | 27 +++++ 4 files changed, 281 insertions(+), 375 deletions(-) delete mode 100644 _server.js diff --git a/_server.js b/_server.js deleted file mode 100644 index 2221c47..0000000 --- a/_server.js +++ /dev/null @@ -1,306 +0,0 @@ -// 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 index cbc7b9c..88e1075 100644 --- a/public/app.js +++ b/public/app.js @@ -61,17 +61,17 @@ document.addEventListener('DOMContentLoaded', () => { btnSummary.addEventListener('click', () => { btnSummary.classList.add('active'); btnLog.classList.remove('active'); - if(summaryListEl) summaryListEl.classList.remove('hidden'); - if(logListEl) logListEl.classList.add('hidden'); - if(logIntervalContainer) logIntervalContainer.classList.add('hidden'); + if (summaryListEl) summaryListEl.classList.remove('hidden'); + if (logListEl) logListEl.classList.add('hidden'); + if (logIntervalContainer) logIntervalContainer.classList.add('hidden'); }); btnLog.addEventListener('click', () => { btnLog.classList.add('active'); btnSummary.classList.remove('active'); - if(summaryListEl) summaryListEl.classList.add('hidden'); - if(logListEl) logListEl.classList.remove('hidden'); - if(logIntervalContainer) logIntervalContainer.classList.remove('hidden'); + if (summaryListEl) summaryListEl.classList.add('hidden'); + if (logListEl) logListEl.classList.remove('hidden'); + if (logIntervalContainer) logIntervalContainer.classList.remove('hidden'); }); } @@ -151,13 +151,13 @@ document.addEventListener('DOMContentLoaded', () => { // [신규] 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"] } + 'OBJDET': {name: "객체 탐지", classes: ["person", "car", "motor", "bus", "truck"], defaults: ["person", "car", "motor", "bus", "truck"]}, + 'FIRE': {name: "화재 감지", classes: ["flame", "smoke"], defaults: ["flame", "smoke"]}, + 'CROWD': {name: "군중 위험", classes: ["person", "car", "motor", "bus", "truck"], defaults: ["person"]}, + 'FACEATTR': {name: "얼굴 인식", classes: ["face", "person", "car", "motor", "bus", "truck"], defaults: ["face", "person"]}, + 'ABNORM': {name: "이상 행동", classes: ["fallen", "person", "car", "motor", "bus", "truck"], defaults: ["fallen"]}, + 'LPR': {name: "차량 인식", classes: ["plate", "person", "car", "motor", "bus", "truck"], defaults: ["plate", "car", "motor", "bus", "truck"]}, + 'VIPTRACK': {name: "관심 인물", classes: ["person"], defaults: ["person"]} }; let currentModelCode = 'OBJDET'; @@ -168,11 +168,15 @@ document.addEventListener('DOMContentLoaded', () => { if (zoomToggle) { zoomToggle.addEventListener('change', (e) => { - if(e.target.checked) { - console.log("Zoom In Activated"); - } else { - console.log("Zoom In Deactivated"); - } + const state = e.target.checked ? "IN" : "OUT"; + fetch('/zoom-control', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({zoom: state}) + }) + .then(response => response.json()) + .then(data => console.log(`Zoom control ${state}:`, data)) + .catch(error => console.error('Error setting zoom:', error)); }); } @@ -192,10 +196,18 @@ document.addEventListener('DOMContentLoaded', () => { item.classList.add('active'); const modelCode = item.dataset.model; - if(modelCode) { + if (modelCode) { currentModelCode = modelCode; changeModel(modelCode); updateFilterBar(modelCode); + // 모델 변경 시 Summary 패널 초기화 + if (modelCode === 'FACEATTR' || modelCode === 'ABNORM') { + summaryListEl.className = 'summary-content card-list'; + summaryListEl.innerHTML = '
Waiting for card data...
'; + } else { + summaryListEl.className = 'summary-content'; + summaryListEl.innerHTML = '
No detection
'; + } } }); }); @@ -206,7 +218,7 @@ document.addEventListener('DOMContentLoaded', () => { headers: {'Content-Type': 'application/json'}, body: JSON.stringify({model: modelCode}) }).then(r => console.log(`Model changed to ${modelCode}`)) - .catch(e => console.error(e)); + .catch(e => console.error(e)); } function updateZoomInToggleVisibility(show) { @@ -222,17 +234,23 @@ document.addEventListener('DOMContentLoaded', () => { const filterBar = document.getElementById('filter-bar'); function updateFilterBar(modelCode) { - if(!filterBar) return; + if (!filterBar) return; filterBar.innerHTML = ''; const def = MODEL_DEFINITIONS[modelCode]; - if(!def) return; + if (!def) return; + + const defaults = def.defaults || def.classes; + + // classes와 defaults가 같은지 확인 (순서 무관하게 내용 비교) + const isAllDefault = (def.classes.length === defaults.length) && + def.classes.every(c => defaults.includes(c)); const allLabel = document.createElement('label'); allLabel.className = 'filter-check'; const allInput = document.createElement('input'); allInput.type = 'checkbox'; - allInput.checked = true; + allInput.checked = isAllDefault; allLabel.appendChild(allInput); allLabel.append(' all'); @@ -250,7 +268,7 @@ document.addEventListener('DOMContentLoaded', () => { label.className = 'filter-check'; const input = document.createElement('input'); input.type = 'checkbox'; - input.checked = true; + input.checked = defaults.includes(cls); input.dataset.cls = cls; label.appendChild(input); label.append(` ${cls}`); @@ -276,7 +294,7 @@ document.addEventListener('DOMContentLoaded', () => { function updateActiveFilters(classInputs) { activeClassFilters.clear(); classInputs.forEach(inp => { - if(inp.checked) activeClassFilters.add(inp.dataset.cls); + if (inp.checked) activeClassFilters.add(inp.dataset.cls); }); } @@ -287,16 +305,18 @@ document.addEventListener('DOMContentLoaded', () => { // 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" } } + 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"}}, + 5: {tagName: "이상 행동", classes: {0: "fallen"}} }; function getBoxColor(clsName) { const colorMap = { 'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500', - 'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC' + 'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC', + 'fall': '#FF0000', 'fight': '#FF0000' }; return colorMap[clsName] || '#FFFFFF'; } @@ -313,15 +333,17 @@ document.addEventListener('DOMContentLoaded', () => { let frameCount = 0; let lastFpsCheckTime = performance.now(); let lastFrameMeta = null; - let viewConfig = { r: 1, dx: 0, dy: 0 }; + 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.onopen = () => { + if (connMsgEl) connMsgEl.textContent = "영상 수신 중"; + }; ws.onclose = () => { - if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)"; + if (connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)"; setTimeout(connect, 2000); }; @@ -329,19 +351,24 @@ document.addEventListener('DOMContentLoaded', () => { const data = event.data; if (typeof data === "string") { updateLogPanel(data); - try { const meta = JSON.parse(data); if (meta.type === "frame") { lastFrameMeta = meta; showDetections(meta); + } else if (meta.type === "card" && (currentModelCode === 'FACEATTR' || currentModelCode === 'ABNORM')) { + lastFrameMeta = meta; // 카드 데이터도 BBox를 포함할 수 있으므로 저장 + showDetections(meta); // BBox 그리기 + updateCardPanel(meta.items); // 카드 패널 업데이트 } - } catch(e){ console.error(e); } + } 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`; + if (fpsDisplayEl) fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`; frameCount = 0; lastFpsCheckTime = now; } @@ -358,7 +385,7 @@ document.addEventListener('DOMContentLoaded', () => { const dh = bmp.height * r; const dx = (canvasEl.width - dw) / 2; const dy = (canvasEl.height - dh) / 2; - viewConfig = { r, dx, dy }; + viewConfig = {r, dx, dy}; ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); ctx.drawImage(bmp, dx, dy, dw, dh); bmp.close(); @@ -387,14 +414,16 @@ document.addEventListener('DOMContentLoaded', () => { return; } - if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0; + 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 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 {r, dx, dy} = viewConfig; const screenX = dx + (x1 * r); const screenY = dy + (y1 * r); const screenW = (x2 - x1) * r; @@ -421,11 +450,13 @@ document.addEventListener('DOMContentLoaded', () => { label.textContent = displayClassName; boxDiv.appendChild(label); } - + bboxContainerEl.appendChild(boxDiv); }); - updateSummaryPanel(currentCounts); + if (currentModelCode !== 'FACEATTR' && currentModelCode !== 'ABNORM') { + updateSummaryPanel(currentCounts); + } } function updateSummaryPanel(counts) { @@ -446,6 +477,45 @@ document.addEventListener('DOMContentLoaded', () => { }); } + function updateCardPanel(items) { + if (!summaryListEl) return; + summaryListEl.innerHTML = ''; // 기존 내용 초기화 + + if (!items || items.length === 0) { + summaryListEl.innerHTML = '
No card data
'; + return; + } + + items.forEach(item => { + const card = document.createElement('div'); + card.className = 'card'; + card.dataset.tid = item.tid; + + if (currentModelCode === 'FACEATTR') { + const personImgSrc = item.person ? `data:image/jpeg;base64,${item.person}` : ''; + const faceImgSrc = item.face ? `data:image/jpeg;base64,${item.face}` : ''; + + card.innerHTML = ` + +
+ +
${item.appear || ''}
+
+ `; + } else if (currentModelCode === 'ABNORM') { + const fallenImgSrc = item.fallen ? `data:image/jpeg;base64,${item.fallen}` : ''; + + card.innerHTML = ` + +
+
쓰러짐 발생
+
+ `; + } + summaryListEl.appendChild(card); + }); + } + function updateBboxContainerPosition() { if (!canvasEl || !bboxContainerEl) return; bboxContainerEl.style.top = canvasEl.offsetTop + 'px'; @@ -486,26 +556,33 @@ document.addEventListener('DOMContentLoaded', () => { 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 => { + 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'); } + 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) { + if (modelTableBody) { modelTableBody.addEventListener('click', (e) => { - if(e.target.classList.contains('btn-delete')) { + 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); + if (filename && filename !== '-') deleteModelFile(filename); } }); } @@ -514,11 +591,15 @@ document.addEventListener('DOMContentLoaded', () => { if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return; fetch('/delete-model', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + 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}`); } + if (data.status === 'success') { + alert('삭제되었습니다.'); + loadModelList(); + } else { + alert(`삭제 실패: ${data.message}`); + } }); } @@ -537,11 +618,17 @@ document.addEventListener('DOMContentLoaded', () => { if (!globalFileInput.files.length) return alert('파일을 선택해주세요.'); const formData = new FormData(); formData.append('modelFile', globalFileInput.files[0]); - fetch('/upload-model', { method: 'POST', body: formData }) + 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 (data.status === 'success') { + alert('업로드 성공'); + globalFileInput.value = ''; + fileNameDisplay.textContent = '선택된 파일 없음'; + loadModelList(); + } else { + alert(`실패: ${data.message}`); + } }); }); } @@ -590,42 +677,87 @@ document.addEventListener('DOMContentLoaded', () => { .catch(err => console.error('POI Load Error:', err)); } - window.openImageViewer = function(src) { + 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) { + window.deletePoi = function (name) { if (!confirm(`관심인물 '${name}' 삭제?`)) return; - fetch('/poi/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(res => res.json()).then(data => { if (data.status === 'success') loadPoiList(); }); + 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) { + 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(); }); + 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) { + window.uploadPoiImage = function (name, input) { if (!input.files || input.files.length === 0) return; const formData = new FormData(); formData.append('poiName', name); - for (let i = 0; i < input.files.length; i++) { formData.append('poiFile', input.files[i]); } - fetch('/poi/upload-image', { method: 'POST', body: formData }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('완료'); loadPoiList(); } else { alert('실패'); } }); + 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('이름 입력'); - 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('실패'); } }); + 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('실패'); + } + }); }); } diff --git a/public/style.css b/public/style.css index 7c0f21b..ec164d3 100644 --- a/public/style.css +++ b/public/style.css @@ -324,6 +324,59 @@ main { .log-time { color: #888; font-weight: bold; margin-right: 5px; } .log-msg { color: #0f0; } /* 메시지는 녹색 터미널 느낌 */ +/* ========================================================== */ +/* 6. [신규] 얼굴 인식 카드 스타일 */ +/* ========================================================== */ +.card-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.card { + background-color: #3a3a3a; + border: 1px solid #555; + border-radius: 4px; + display: flex; + padding: 10px; + gap: 10px; +} + +.card-person-img { + width: 100px; + height: 150px; + object-fit: cover; + border-radius: 4px; + background-color: #2a2a2a; +} + +.card-right { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 10px; +} + +.card-face-img { + width: 60px; + height: 60px; + object-fit: cover; + border-radius: 4px; + background-color: #2a2a2a; +} + +.card-appear-info { + background-color: #2a2a2a; + padding: 8px; + border-radius: 4px; + font-size: 12px; + /*color: #e0e0e0;*/ + color: #FF0000; + flex-grow: 1; + white-space: pre-wrap; /* 줄바꿈 적용 */ +} + + /* ========================================================== */ /* 7. 설정 탭 - AI 모델 & 관심 인물 관리 (Settings) */ /* ========================================================== */ diff --git a/server.js b/server.js index 2221c47..7b65d1d 100644 --- a/server.js +++ b/server.js @@ -123,6 +123,33 @@ app.post('/set-model', (req, res) => { }); }); +app.post('/zoom-control', (req, res) => { + const { zoom } = req.body; + if (!zoom || (zoom !== 'IN' && zoom !== 'OUT')) { + return res.status(400).json({ status: 'error', message: 'Invalid zoom state' }); + } + + const scriptPath = '/mnt/user_data/feat_control/zoom_control.sh'; + const scriptProcess = spawn(scriptPath, [zoom]); + + let output = ''; + scriptProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + scriptProcess.stderr.on('data', (data) => { + output += data.toString(); + }); + + scriptProcess.on('close', (code) => { + if (code === 0) { + res.json({ status: 'success', message: `Zoom set to ${zoom}`, output }); + } else { + res.status(500).json({ status: 'error', message: `Script failed with code ${code}`, output }); + } + }); +}); + 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 });