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 });