얼굴인식, 이상행동 감지 카드목록 작업.

main
dongjin kim 6 months ago
parent 8600ed534b
commit 962fb2c3c3

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

@ -61,17 +61,17 @@ document.addEventListener('DOMContentLoaded', () => {
btnSummary.addEventListener('click', () => { btnSummary.addEventListener('click', () => {
btnSummary.classList.add('active'); btnSummary.classList.add('active');
btnLog.classList.remove('active'); btnLog.classList.remove('active');
if(summaryListEl) summaryListEl.classList.remove('hidden'); if (summaryListEl) summaryListEl.classList.remove('hidden');
if(logListEl) logListEl.classList.add('hidden'); if (logListEl) logListEl.classList.add('hidden');
if(logIntervalContainer) logIntervalContainer.classList.add('hidden'); if (logIntervalContainer) logIntervalContainer.classList.add('hidden');
}); });
btnLog.addEventListener('click', () => { btnLog.addEventListener('click', () => {
btnLog.classList.add('active'); btnLog.classList.add('active');
btnSummary.classList.remove('active'); btnSummary.classList.remove('active');
if(summaryListEl) summaryListEl.classList.add('hidden'); if (summaryListEl) summaryListEl.classList.add('hidden');
if(logListEl) logListEl.classList.remove('hidden'); if (logListEl) logListEl.classList.remove('hidden');
if(logIntervalContainer) logIntervalContainer.classList.remove('hidden'); if (logIntervalContainer) logIntervalContainer.classList.remove('hidden');
}); });
} }
@ -151,13 +151,13 @@ document.addEventListener('DOMContentLoaded', () => {
// [신규] Mission UI 로직 // [신규] Mission UI 로직
// ================================================= // =================================================
const MODEL_DEFINITIONS = { const MODEL_DEFINITIONS = {
'OBJDET': { name: "객체 탐지", classes: ["person", "car", "van", "truck", "bus", "motor"] }, 'OBJDET': {name: "객체 탐지", classes: ["person", "car", "motor", "bus", "truck"], defaults: ["person", "car", "motor", "bus", "truck"]},
'FIRE': { name: "화재 감지", classes: ["flame", "smoke"] }, 'FIRE': {name: "화재 감지", classes: ["flame", "smoke"], defaults: ["flame", "smoke"]},
'CROWD': { name: "군중 위험", classes: ["person", "crowd"] }, 'CROWD': {name: "군중 위험", classes: ["person", "car", "motor", "bus", "truck"], defaults: ["person"]},
'FACEATTR': { name: "얼굴 인식", classes: ["face"] }, 'FACEATTR': {name: "얼굴 인식", classes: ["face", "person", "car", "motor", "bus", "truck"], defaults: ["face", "person"]},
'ABNORM': { name: "이상 행동", classes: ["fall", "fight"] }, 'ABNORM': {name: "이상 행동", classes: ["fallen", "person", "car", "motor", "bus", "truck"], defaults: ["fallen"]},
'LPR': { name: "차량 인식", classes: ["plate"] }, 'LPR': {name: "차량 인식", classes: ["plate", "person", "car", "motor", "bus", "truck"], defaults: ["plate", "car", "motor", "bus", "truck"]},
'VIPTRACK': { name: "관심 인물", classes: ["person"] } 'VIPTRACK': {name: "관심 인물", classes: ["person"], defaults: ["person"]}
}; };
let currentModelCode = 'OBJDET'; let currentModelCode = 'OBJDET';
@ -168,11 +168,15 @@ document.addEventListener('DOMContentLoaded', () => {
if (zoomToggle) { if (zoomToggle) {
zoomToggle.addEventListener('change', (e) => { zoomToggle.addEventListener('change', (e) => {
if(e.target.checked) { const state = e.target.checked ? "IN" : "OUT";
console.log("Zoom In Activated"); fetch('/zoom-control', {
} else { method: 'POST',
console.log("Zoom In Deactivated"); 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'); item.classList.add('active');
const modelCode = item.dataset.model; const modelCode = item.dataset.model;
if(modelCode) { if (modelCode) {
currentModelCode = modelCode; currentModelCode = modelCode;
changeModel(modelCode); changeModel(modelCode);
updateFilterBar(modelCode); updateFilterBar(modelCode);
// 모델 변경 시 Summary 패널 초기화
if (modelCode === 'FACEATTR' || modelCode === 'ABNORM') {
summaryListEl.className = 'summary-content card-list';
summaryListEl.innerHTML = '<div style="color:#777; text-align:center; padding:10px;">Waiting for card data...</div>';
} else {
summaryListEl.className = 'summary-content';
summaryListEl.innerHTML = '<div style="color:#777; text-align:center; padding:10px;">No detection</div>';
}
} }
}); });
}); });
@ -206,7 +218,7 @@ document.addEventListener('DOMContentLoaded', () => {
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: modelCode}) body: JSON.stringify({model: modelCode})
}).then(r => console.log(`Model changed to ${modelCode}`)) }).then(r => console.log(`Model changed to ${modelCode}`))
.catch(e => console.error(e)); .catch(e => console.error(e));
} }
function updateZoomInToggleVisibility(show) { function updateZoomInToggleVisibility(show) {
@ -222,17 +234,23 @@ document.addEventListener('DOMContentLoaded', () => {
const filterBar = document.getElementById('filter-bar'); const filterBar = document.getElementById('filter-bar');
function updateFilterBar(modelCode) { function updateFilterBar(modelCode) {
if(!filterBar) return; if (!filterBar) return;
filterBar.innerHTML = ''; filterBar.innerHTML = '';
const def = MODEL_DEFINITIONS[modelCode]; 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'); const allLabel = document.createElement('label');
allLabel.className = 'filter-check'; allLabel.className = 'filter-check';
const allInput = document.createElement('input'); const allInput = document.createElement('input');
allInput.type = 'checkbox'; allInput.type = 'checkbox';
allInput.checked = true; allInput.checked = isAllDefault;
allLabel.appendChild(allInput); allLabel.appendChild(allInput);
allLabel.append(' all'); allLabel.append(' all');
@ -250,7 +268,7 @@ document.addEventListener('DOMContentLoaded', () => {
label.className = 'filter-check'; label.className = 'filter-check';
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = true; input.checked = defaults.includes(cls);
input.dataset.cls = cls; input.dataset.cls = cls;
label.appendChild(input); label.appendChild(input);
label.append(` ${cls}`); label.append(` ${cls}`);
@ -276,7 +294,7 @@ document.addEventListener('DOMContentLoaded', () => {
function updateActiveFilters(classInputs) { function updateActiveFilters(classInputs) {
activeClassFilters.clear(); activeClassFilters.clear();
classInputs.forEach(inp => { 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 패널 // 2. 비디오 & 웹소켓 & Summary 패널
// ================================================= // =================================================
const LABEL_MAP = { const LABEL_MAP = {
1: { tagName: "객체 탐지", classes: { 0: "person", 1: "car", 2: "van", 3: "truck", 4: "bus", 5: "motor" } }, 1: {tagName: "객체 탐지", classes: {0: "person", 1: "car", 2: "van", 3: "truck", 4: "bus", 5: "motor"}},
2: { tagName: "화재 인식", classes: { 0: "flame", 1: "smoke" } }, 2: {tagName: "화재 인식", classes: {0: "flame", 1: "smoke"}},
3: { tagName: "얼굴 인식", classes: { 0: "face" } }, 3: {tagName: "얼굴 인식", classes: {0: "face"}},
4: { tagName: "차량번호", classes: { 0: "plate" } } 4: {tagName: "차량번호", classes: {0: "plate"}},
5: {tagName: "이상 행동", classes: {0: "fallen"}}
}; };
function getBoxColor(clsName) { function getBoxColor(clsName) {
const colorMap = { const colorMap = {
'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500', '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'; return colorMap[clsName] || '#FFFFFF';
} }
@ -313,15 +333,17 @@ document.addEventListener('DOMContentLoaded', () => {
let frameCount = 0; let frameCount = 0;
let lastFpsCheckTime = performance.now(); let lastFpsCheckTime = performance.now();
let lastFrameMeta = null; let lastFrameMeta = null;
let viewConfig = { r: 1, dx: 0, dy: 0 }; let viewConfig = {r: 1, dx: 0, dy: 0};
function connect() { function connect() {
const ws = new WebSocket(uri); const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer"; ws.binaryType = "arraybuffer";
ws.onopen = () => { if(connMsgEl) connMsgEl.textContent = "영상 수신 중"; }; ws.onopen = () => {
if (connMsgEl) connMsgEl.textContent = "영상 수신 중";
};
ws.onclose = () => { ws.onclose = () => {
if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)"; if (connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)";
setTimeout(connect, 2000); setTimeout(connect, 2000);
}; };
@ -329,19 +351,24 @@ document.addEventListener('DOMContentLoaded', () => {
const data = event.data; const data = event.data;
if (typeof data === "string") { if (typeof data === "string") {
updateLogPanel(data); updateLogPanel(data);
try { try {
const meta = JSON.parse(data); const meta = JSON.parse(data);
if (meta.type === "frame") { if (meta.type === "frame") {
lastFrameMeta = meta; lastFrameMeta = meta;
showDetections(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) { } else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++; frameCount++;
const now = performance.now(); const now = performance.now();
if (now - lastFpsCheckTime >= 1000) { 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; frameCount = 0;
lastFpsCheckTime = now; lastFpsCheckTime = now;
} }
@ -358,7 +385,7 @@ document.addEventListener('DOMContentLoaded', () => {
const dh = bmp.height * r; const dh = bmp.height * r;
const dx = (canvasEl.width - dw) / 2; const dx = (canvasEl.width - dw) / 2;
const dy = (canvasEl.height - dh) / 2; const dy = (canvasEl.height - dh) / 2;
viewConfig = { r, dx, dy }; viewConfig = {r, dx, dy};
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
ctx.drawImage(bmp, dx, dy, dw, dh); ctx.drawImage(bmp, dx, dy, dw, dh);
bmp.close(); bmp.close();
@ -387,14 +414,16 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0; if (!currentCounts[displayClassName]) currentCounts[displayClassName] = 0;
currentCounts[displayClassName]++; currentCounts[displayClassName]++;
const x1 = it.x1 || 0; const y1 = it.y1 || 0; const x1 = it.x1 || 0;
const x2 = it.x2 || 0; const y2 = it.y2 || 0; const y1 = it.y1 || 0;
const x2 = it.x2 || 0;
const y2 = it.y2 || 0;
const boxColor = getBoxColor(displayClassName); const boxColor = getBoxColor(displayClassName);
const { r, dx, dy } = viewConfig; const {r, dx, dy} = viewConfig;
const screenX = dx + (x1 * r); const screenX = dx + (x1 * r);
const screenY = dy + (y1 * r); const screenY = dy + (y1 * r);
const screenW = (x2 - x1) * r; const screenW = (x2 - x1) * r;
@ -425,7 +454,9 @@ document.addEventListener('DOMContentLoaded', () => {
bboxContainerEl.appendChild(boxDiv); bboxContainerEl.appendChild(boxDiv);
}); });
updateSummaryPanel(currentCounts); if (currentModelCode !== 'FACEATTR' && currentModelCode !== 'ABNORM') {
updateSummaryPanel(currentCounts);
}
} }
function updateSummaryPanel(counts) { 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 = '<div style="color:#777; text-align:center; padding:10px;">No card data</div>';
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 = `
<img src="${personImgSrc}" class="card-person-img">
<div class="card-right">
<img src="${faceImgSrc}" class="card-face-img">
<div class="card-appear-info">${item.appear || ''}</div>
</div>
`;
} else if (currentModelCode === 'ABNORM') {
const fallenImgSrc = item.fallen ? `data:image/jpeg;base64,${item.fallen}` : '';
card.innerHTML = `
<img src="${fallenImgSrc}" class="card-person-img" style="width:120px; height:auto; max-height:150px;">
<div class="card-right">
<div class="card-appear-info">쓰러짐 발생</div>
</div>
`;
}
summaryListEl.appendChild(card);
});
}
function updateBboxContainerPosition() { function updateBboxContainerPosition() {
if (!canvasEl || !bboxContainerEl) return; if (!canvasEl || !bboxContainerEl) return;
bboxContainerEl.style.top = canvasEl.offsetTop + 'px'; bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
@ -486,26 +556,33 @@ document.addEventListener('DOMContentLoaded', () => {
function loadModelList() { function loadModelList() {
const tableBody = document.querySelector('.model-table tbody'); const tableBody = document.querySelector('.model-table tbody');
if (!tableBody) return; if (!tableBody) return;
fetch('/list-models').then(r=>r.json()).then(models=>{ fetch('/list-models').then(r => r.json()).then(models => {
tableBody.querySelectorAll('tr[data-role]').forEach(row => { tableBody.querySelectorAll('tr[data-role]').forEach(row => {
const role = row.dataset.role; const role = row.dataset.role;
const d = models[role]; const d = models[role];
const f = row.querySelector('.model-filename'); const f = row.querySelector('.model-filename');
const v = row.querySelector('.model-version'); const v = row.querySelector('.model-version');
const btn = row.querySelector('.btn-delete'); const btn = row.querySelector('.btn-delete');
if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); } if (d) {
else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); } 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'); const modelTableBody = document.querySelector('.model-table tbody');
if(modelTableBody) { if (modelTableBody) {
modelTableBody.addEventListener('click', (e) => { 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 row = e.target.closest('tr');
const filename = row.querySelector('.model-filename').textContent; 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; if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
fetch('/delete-model', { fetch('/delete-model', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({filename: filename}) body: JSON.stringify({filename: filename})
}).then(res => res.json()).then(data => { }).then(res => res.json()).then(data => {
if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); } if (data.status === 'success') {
else { alert(`삭제 실패: ${data.message}`); } alert('삭제되었습니다.');
loadModelList();
} else {
alert(`삭제 실패: ${data.message}`);
}
}); });
} }
@ -537,11 +618,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (!globalFileInput.files.length) return alert('파일을 선택해주세요.'); if (!globalFileInput.files.length) return alert('파일을 선택해주세요.');
const formData = new FormData(); const formData = new FormData();
formData.append('modelFile', globalFileInput.files[0]); 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(res => res.json())
.then(data => { .then(data => {
if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); } if (data.status === 'success') {
else { alert(`실패: ${data.message}`); } 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)); .catch(err => console.error('POI Load Error:', err));
} }
window.openImageViewer = function(src) { window.openImageViewer = function (src) {
if (fullImage && imageViewerModal) { if (fullImage && imageViewerModal) {
fullImage.src = src; fullImage.src = src;
imageViewerModal.classList.remove('hidden'); imageViewerModal.classList.remove('hidden');
} }
}; };
if (closeViewerBtn) { if (closeViewerBtn) {
closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); }); closeViewerBtn.addEventListener('click', () => {
imageViewerModal.classList.add('hidden');
});
} }
window.addEventListener('click', (e) => { 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; 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; 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; if (!input.files || input.files.length === 0) return;
const formData = new FormData(); const formData = new FormData();
formData.append('poiName', name); formData.append('poiName', name);
for (let i = 0; i < input.files.length; i++) { formData.append('poiFile', input.files[i]); } for (let i = 0; i < input.files.length; i++) {
fetch('/poi/upload-image', { method: 'POST', body: formData }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('완료'); loadPoiList(); } else { alert('실패'); } }); 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 = ''; input.value = '';
}; };
if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList); if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
if (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); } if (btnPoiRegister) {
if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); } btnPoiRegister.addEventListener('click', () => {
poiNameInput.value = '';
poiModal.classList.remove('hidden');
});
}
if (btnModalCancel) {
btnModalCancel.addEventListener('click', () => {
poiModal.classList.add('hidden');
});
}
if (btnModalConfirm) { if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => { btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim(); const name = poiNameInput.value.trim();
if (!name) return alert('이름 입력'); 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('실패');
}
});
}); });
} }

@ -324,6 +324,59 @@ main {
.log-time { color: #888; font-weight: bold; margin-right: 5px; } .log-time { color: #888; font-weight: bold; margin-right: 5px; }
.log-msg { color: #0f0; } /* 메시지는 녹색 터미널 느낌 */ .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) */ /* 7. 설정 탭 - AI 모델 & 관심 인물 관리 (Settings) */
/* ========================================================== */ /* ========================================================== */

@ -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) => { app.post('/upload-model', uploadModel.single('modelFile'), (req, res) => {
if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' }); if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' });
res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path }); res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path });

Loading…
Cancel
Save