Renewal design.

main
dongjin kim 6 months ago
parent 448b9e3489
commit a3ccc7bb92

@ -0,0 +1,306 @@
// server.js
// 1. 모듈 불러오기
const express = require('express');
const path = require('path');
const { spawn } = require('child_process');
const multer = require('multer');
const fs = require('fs');
// 2. Express 앱 생성
const app = express();
const port = 3000;
// ========================================================
// [설정] 경로 및 폴더 초기화
// ========================================================
// AI 모델용 업로드 경로
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
fs.mkdirSync(uploadPath, { recursive: true });
// 관심 인물(POI) 폴더 경로 (vip_tracks)
const vipTracksPath = '/mnt/user_data/applications/misc/vip_tracks';
fs.mkdirSync(vipTracksPath, { recursive: true });
// ========================================================
// [설정] Multer (파일 업로드 핸들러)
// ========================================================
// 1) AI 모델용 스토리지
const modelStorage = multer.diskStorage({
destination: (req, file, cb) => { cb(null, uploadPath); },
filename: (req, file, cb) => { cb(null, file.originalname); }
});
const modelFileFilter = (req, file, cb) => {
if (!file.originalname.endsWith('.aiwbin')) {
return cb(new Error('Invalid file type: Only .aiwbin files are allowed'), false);
}
cb(null, true);
};
const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter });
// 2) 관심 인물(POI) 이미지용 스토리지
const poiStorage = multer.diskStorage({
destination: (req, file, cb) => { cb(null, vipTracksPath); },
filename: (req, file, cb) => {
// 임시 저장을 위해 충돌 없는 이름을 생성
const ext = path.extname(file.originalname);
cb(null, `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${ext}`);
}
});
const poiFileFilter = (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.jpg', '.jpeg', '.png'].includes(ext)) {
return cb(new Error('이미지 파일만 업로드 가능합니다 (.jpg, .png)'), false);
}
cb(null, true);
};
const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter });
// ========================================================
// [미들웨어 및 라우트 설정]
// ========================================================
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// POI 이미지 서빙
app.get('/poi-images/:folder/:filename', (req, res) => {
const { folder, filename } = req.params;
if (folder.includes('..') || filename.includes('..')) {
return res.status(400).send('Invalid path');
}
const filePath = path.join(vipTracksPath, folder, filename);
if (fs.existsSync(filePath)) {
res.sendFile(filePath);
} else {
res.status(404).send('Image not found');
}
});
// 페이지 라우팅
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/dashboard', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// ========================================================
// [API] AI 모델 관련
// ========================================================
app.post('/set-model', (req, res) => {
const { model } = req.body;
if (!model) return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' });
const scriptCommand = '/mnt/user_data/feat_control/feat_on.sh';
const args = [model];
const scriptProcess = spawn(scriptCommand, args);
let stdoutData = '';
scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); });
scriptProcess.on('close', (code) => {
if (code === 0) {
res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData });
} else {
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' });
}
});
});
app.post('/upload-model', uploadModel.single('modelFile'), (req, res) => {
if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' });
res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path });
});
function isVersionGreater(v1, v2) {
if (!v2) return true;
const parts1 = v1.replace('v', '').split('.').map(Number);
const parts2 = v2.replace('v', '').split('.').map(Number);
if (parts1[0] > parts2[0]) return true;
if (parts1[0] < parts2[0]) return false;
if (parts1[1] > parts2[1]) return true;
return false;
}
app.get('/list-models', (req, res) => {
const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/;
const latestModels = {};
fs.readdir(uploadPath, (err, files) => {
if (err) return res.status(500).json({ status: 'error' });
files.forEach(file => {
const match = file.match(modelRegex);
if (match) {
const role = match[1];
const version = `v${match[2]}`;
const existing = latestModels[role];
if (!existing || isVersionGreater(version, existing.version)) {
latestModels[role] = { file: file, version: version };
}
}
});
res.json(latestModels);
});
});
app.post('/delete-model', (req, res) => {
const { filename } = req.body;
if (!filename) return res.status(400).json({ status: 'error', message: '파일명 누락' });
const safeFilename = path.basename(filename);
if (!safeFilename.endsWith('.aiwbin')) return res.status(400).json({ status: 'error', message: '잘못된 파일' });
const filePath = path.join(uploadPath, safeFilename);
fs.unlink(filePath, (err) => {
if (err && err.code !== 'ENOENT') return res.status(500).json({ status: 'error' });
res.json({ status: 'success', message: '삭제 완료' });
});
});
// ========================================================
// [API] 관심 인물(POI) 관리 관련
// ========================================================
app.get('/poi/list', (req, res) => {
fs.readdir(vipTracksPath, { withFileTypes: true }, (err, entries) => {
if (err) return res.json([]);
const result = entries
.filter(dirent => dirent.isDirectory())
.map(dirent => {
const name = dirent.name;
const dirPath = path.join(vipTracksPath, name);
let imageFiles = [];
try {
const files = fs.readdirSync(dirPath);
imageFiles = files.filter(file => /\.(jpg|jpeg|png)$/i.test(file));
} catch (e) {
imageFiles = [];
}
return { name, images: imageFiles };
});
res.json(result);
});
});
app.post('/poi/register', (req, res) => {
const { name } = req.body;
const nameRegex = /^[a-zA-Z0-9]+$/;
if (!name || !nameRegex.test(name)) {
return res.status(400).json({ status: 'error', message: '이름은 영문과 숫자만 가능합니다.' });
}
const targetDir = path.join(vipTracksPath, name);
if (fs.existsSync(targetDir)) {
return res.status(400).json({ status: 'error', message: '이미 등록된 이름입니다.' });
}
try {
fs.mkdirSync(targetDir);
res.json({ status: 'success' });
} catch (err) {
res.status(500).json({ status: 'error', message: '폴더 생성 실패' });
}
});
app.post('/poi/delete', (req, res) => {
const { name } = req.body;
const safeName = path.basename(name);
const targetDir = path.join(vipTracksPath, safeName);
try {
if (fs.existsSync(targetDir)) {
fs.rmSync(targetDir, { recursive: true, force: true });
}
res.json({ status: 'success' });
} catch (err) {
res.status(500).json({ status: 'error', message: '삭제 실패' });
}
});
app.post('/poi/delete-image', (req, res) => {
const { name, imageName } = req.body;
if (!name || !imageName) return res.status(400).json({ status: 'error', message: '정보 누락' });
const safeName = path.basename(name);
const safeImageName = path.basename(imageName);
const targetFile = path.join(vipTracksPath, safeName, safeImageName);
if (fs.existsSync(targetFile)) {
try {
fs.unlinkSync(targetFile);
res.json({ status: 'success', message: '이미지가 삭제되었습니다.' });
} catch (err) {
res.status(500).json({ status: 'error', message: '파일 삭제 오류' });
}
} else {
res.status(404).json({ status: 'error', message: '이미지가 없습니다.' });
}
});
// [수정됨] 다중 파일 업로드 처리 (uploadPoi.array 사용)
app.post('/poi/upload-image', uploadPoi.array('poiFile'), (req, res) => {
// 다중 파일인 경우 req.files에 배열로 들어옴
if (!req.files || req.files.length === 0) {
return res.status(400).json({ status: 'error', message: '파일 업로드 실패 (jpg, png만 가능)' });
}
const poiName = req.body.poiName;
// poiName이 없으면 임시 파일 모두 삭제
if (!poiName) {
req.files.forEach(file => {
if(fs.existsSync(file.path)) fs.unlinkSync(file.path);
});
return res.status(400).json({ status: 'error', message: '대상 인물 정보 누락' });
}
const targetDir = path.join(vipTracksPath, poiName);
try {
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir);
}
// 업로드된 모든 파일을 순회하며 이동
req.files.forEach(file => {
const ext = path.extname(file.originalname).toLowerCase();
// 파일명 중복 방지를 위해 난수 추가
const newFileName = `img_${Date.now()}_${Math.floor(Math.random() * 100000)}${ext}`;
const targetPath = path.join(targetDir, newFileName);
fs.renameSync(file.path, targetPath);
});
res.json({ status: 'success', message: '이미지 등록 완료' });
} catch (err) {
console.error(err);
// 에러 발생 시 남아있는 임시 파일 정리
req.files.forEach(file => {
if(fs.existsSync(file.path)) try { fs.unlinkSync(file.path); } catch(e){}
});
res.status(500).json({ status: 'error', message: '파일 이동 실패' });
}
});
// 서버 실행
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});

@ -0,0 +1,610 @@
// app.js
document.addEventListener('DOMContentLoaded', () => {
// =================================================
// 0. 로그아웃 로직
// =================================================
const logoutBtn = document.getElementById('logout-button');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
if (confirm('정말로 로그아웃 하시겠습니까?')) {
// 로그인 페이지(루트)로 이동
window.location.href = '/';
}
});
}
// =================================================
// 1. 탭 전환 로직
// =================================================
const tabVideo = document.getElementById('tab-video');
const tabModels = document.getElementById('tab-models');
const contentVideo = document.getElementById('content-video');
const contentModels = document.getElementById('content-models');
if (tabVideo && tabModels) {
tabVideo.addEventListener('click', () => {
tabVideo.classList.add('active');
tabModels.classList.remove('active');
contentVideo.classList.add('active');
contentModels.classList.remove('active');
});
tabModels.addEventListener('click', () => {
tabModels.classList.add('active');
tabVideo.classList.remove('active');
contentModels.classList.add('active');
contentVideo.classList.remove('active');
loadModelList();
loadPoiList();
});
}
// =================================================
// 2. AI 모델 관리 로직
// =================================================
function loadModelList() {
const tableBody = document.querySelector('.model-table tbody');
if (!tableBody) return;
fetch('/list-models').then(r=>r.json()).then(models=>{
tableBody.querySelectorAll('tr[data-role]').forEach(row => {
const role = row.dataset.role;
const d = models[role];
const f = row.querySelector('.model-filename');
const v = row.querySelector('.model-version');
const btn = row.querySelector('.btn-delete');
if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); }
else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); }
});
});
}
const modelTableBody = document.querySelector('.model-table tbody');
if(modelTableBody) {
modelTableBody.addEventListener('click', (e) => {
if(e.target.classList.contains('btn-delete')) {
const row = e.target.closest('tr');
const filename = row.querySelector('.model-filename').textContent;
if(filename && filename !== '-') deleteModelFile(filename);
}
});
}
function deleteModelFile(filename) {
if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
fetch('/delete-model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({filename: filename})
}).then(res => res.json()).then(data => {
if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); }
else { alert(`삭제 실패: ${data.message}`); }
});
}
const globalUploadBtn = document.getElementById('global-upload-button');
const globalFileInput = document.getElementById('global-file-input');
const fileNameDisplay = document.getElementById('file-name-display');
const refreshButton = document.querySelector('.refresh-button');
if (globalFileInput) {
globalFileInput.addEventListener('change', () => {
fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음';
});
}
if (globalUploadBtn) {
globalUploadBtn.addEventListener('click', () => {
if (!globalFileInput.files.length) return alert('파일을 선택해주세요.');
const formData = new FormData();
formData.append('modelFile', globalFileInput.files[0]);
fetch('/upload-model', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); }
else { alert(`실패: ${data.message}`); }
});
});
}
if (refreshButton) refreshButton.addEventListener('click', loadModelList);
// =================================================
// 3. 관심 인물(POI) 관리 로직
// =================================================
const poiTableBody = document.querySelector('#poi-table tbody');
const poiEmptyMsg = document.getElementById('poi-empty-msg');
const btnPoiRegister = document.getElementById('btn-poi-register');
const btnPoiRefresh = document.getElementById('btn-poi-refresh');
// 모달 및 뷰어 요소
const poiModal = document.getElementById('poi-modal');
const poiNameInput = document.getElementById('poi-name-input');
const btnModalConfirm = document.getElementById('btn-modal-confirm');
const btnModalCancel = document.getElementById('btn-modal-cancel');
const imageViewerModal = document.getElementById('image-viewer-modal');
const fullImage = document.getElementById('full-image');
const closeViewerBtn = document.querySelector('.close-viewer');
// POI 목록 로드
function loadPoiList() {
if (!poiTableBody) return;
fetch('/poi/list')
.then(res => res.json())
.then(list => {
poiTableBody.innerHTML = '';
if (!list || list.length === 0) {
poiEmptyMsg.classList.remove('hidden');
poiTableBody.parentElement.classList.add('hidden');
} else {
poiEmptyMsg.classList.add('hidden');
poiTableBody.parentElement.classList.remove('hidden');
list.forEach((item, index) => {
const tr = document.createElement('tr');
let previewHtml = '-';
if (item.images && item.images.length > 0) {
const imagesHtml = item.images.map(imgName => {
const imgSrc = `/poi-images/${item.name}/${imgName}`;
return `
<div class="preview-item">
<img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')" title="클릭하여 확대">
<button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button>
</div>
`;
}).join('');
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
}
const fileInputId = `poi-file-${item.name}`;
tr.innerHTML = `
<td>${index + 1}</td>
<td>
<span style="font-weight:bold; font-size:1.1rem; margin-right:10px;">${item.name}</span>
<button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button>
</td>
<td>${previewHtml}</td>
<td>
<input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)">
<label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333; border:1px solid #ccc;">찾아보기</label>
</td>
`;
poiTableBody.appendChild(tr);
});
}
})
.catch(err => console.error('POI Load Error:', err));
}
// [전역 함수] 이미지 뷰어 열기
window.openImageViewer = function(src) {
if (fullImage && imageViewerModal) {
fullImage.src = src;
imageViewerModal.classList.remove('hidden');
}
};
// 뷰어 닫기 로직
if (closeViewerBtn) {
closeViewerBtn.addEventListener('click', () => {
imageViewerModal.classList.add('hidden');
});
}
window.addEventListener('click', (e) => {
if (e.target === imageViewerModal) {
imageViewerModal.classList.add('hidden');
}
});
// [전역 함수] 인물 전체 삭제
window.deletePoi = function(name) {
if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return;
fetch('/poi/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('삭제 실패');
});
};
// [전역 함수] 특정 이미지만 삭제
window.deletePoiImage = function(name, imageName) {
if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return;
fetch('/poi/delete-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, imageName })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('이미지 삭제 실패');
});
};
// 다중 파일 업로드 처리
window.uploadPoiImage = function(name, input) {
if (!input.files || input.files.length === 0) return;
const formData = new FormData();
formData.append('poiName', name);
for (let i = 0; i < input.files.length; i++) {
const file = input.files[i];
const ext = file.name.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
input.value = '';
return;
}
formData.append('poiFile', file);
}
fetch('/poi/upload-image', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('이미지 등록 완료');
loadPoiList();
} else {
alert('업로드 실패: ' + (data.message || '오류'));
}
})
.catch(() => alert('오류 발생'));
input.value = '';
};
// 이벤트 리스너
if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
if (btnPoiRegister) {
btnPoiRegister.addEventListener('click', () => {
poiNameInput.value = '';
poiModal.classList.remove('hidden');
});
}
if (btnModalCancel) {
btnModalCancel.addEventListener('click', () => {
poiModal.classList.add('hidden');
});
}
if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim();
if (!name) return alert('이름을 입력하세요.');
const regex = /^[a-zA-Z0-9]+$/;
if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
fetch('/poi/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
poiModal.classList.add('hidden');
loadPoiList();
} else {
alert(data.message || '등록 실패');
}
});
});
}
// =================================================
// 4. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
// =================================================
// [설정] Tag/Class 매핑 테이블
const LABEL_MAP = {
1: {
tagName: "객체 탐지/ 분류",
classes: {
0: "person",
1: "car",
2: "van",
3: "truck",
4: "bus",
5: "motor"
}
},
2: {
tagName: "화재 인식",
classes: {
0: "flame",
1: "smoke"
}
},
3: {
tagName: "얼굴 인식",
classes: {
0: "face"
}
},
4: {
tagName: "차량번호판 및 차종 인식",
classes: {
0: "License plate"
}
}
};
// [설정] 박스 색상 생성 함수
function getBoxColor(tag, cls) {
// Tag 2: 화재 관련 (긴급한 색상)
if (tag === 2) {
if (cls === 0) return '#FF0000'; // Flame: 빨강
if (cls === 1) return '#808080'; // Smoke: 회색
}
// Tag 1: 객체 탐지 (다양한 색상)
if (tag === 1) {
const colors = [
'#00FF00', // Person: 라임 그린
'#00FFFF', // Car: 시안
'#FFA500', // Van: 오렌지
'#FF69B4', // Truck: 핫핑크
'#9370DB', // Bus: 미디엄 퍼플
'#FFD700' // Motor: 골드
];
return colors[cls % colors.length] || '#FFFFFF';
}
// Tag 3: 얼굴
if (tag === 3) return '#1E90FF'; // 도저 블루
// Tag 4: 번호판
if (tag === 4) return '#32CD32'; // 라임 그린
// 기타 기본값
return '#FF0000';
}
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const statusEl = document.getElementById("status");
const detListEl = document.getElementById("det-list");
const bboxContainerEl = document.getElementById("bbox-container");
const modelContainerEl = document.getElementById("current-model-container");
const modelDisplayEl = document.getElementById("current-model-display");
const fpsDisplayEl = document.getElementById("fps-display");
let ctx = null;
if (canvasEl) ctx = canvasEl.getContext('2d');
let frameCount = 0;
let lastFpsCheckTime = performance.now();
let lastFrameMeta = null;
let viewConfig = { r: 1, dx: 0, dy: 0 };
function connect() {
const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
statusEl.textContent = "연결됨";
statusEl.className = "status";
};
ws.onclose = () => {
statusEl.textContent = "연결 종료. 재접속...";
statusEl.className = "status err";
setTimeout(connect, 2000);
};
ws.onmessage = (event) => {
const data = event.data;
// 1. 텍스트 데이터(JSON) 처리
if (typeof data === "string") {
console.log("-----" + data); // 필요시 주석 해제
try {
const meta = JSON.parse(data);
if (meta.type === "frame") {
lastFrameMeta = meta;
document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
showDetections(meta);
}
} catch(e){ console.error(e); }
// 2. 바이너리 데이터(영상 프레임) 처리
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
frameCount = 0;
lastFpsCheckTime = now;
}
const blob = new Blob([data], {type: "image/jpeg"});
createImageBitmap(blob).then(bmp => {
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
const dw = bmp.width * r;
const dh = bmp.height * r;
const dx = (canvasEl.width - dw) / 2;
const dy = (canvasEl.height - dh) / 2;
viewConfig = { r, dx, dy };
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
ctx.drawImage(bmp, dx, dy, dw, dh);
bmp.close();
updateBboxContainerPosition();
});
}
};
}
/**
* [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수
* - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
* - Class별 색상 구분 적용
*/
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const ch = meta.ch !== undefined ? meta.ch : '-';
const seq = meta.seq !== undefined ? meta.seq : '-';
const ts = meta.ts_us !== undefined ? meta.ts_us : '-';
let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`];
const items = meta.items || [];
if (items.length === 0) {
outputLines.push("No detections");
} else {
items.forEach((it, i) => {
const x1 = it.x1 || 0;
const y1 = it.y1 || 0;
const x2 = it.x2 || 0;
const y2 = it.y2 || 0;
// 데이터에서 tag와 cls 추출
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
const tId = it.tid !== undefined ? it.tid : -1;
// 1. 이름 매핑 Lookup
let displayTagName = `Tag ${tagId}`;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId]) {
displayTagName = LABEL_MAP[tagId].tagName;
if (LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
}
// 2. 색상 결정
const boxColor = getBoxColor(tagId, clsId);
// 로그창 출력 (원본 데이터 유지)
outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
// 좌표 변환
const { r, dx, dy } = viewConfig;
const screenX = dx + (x1 * r);
const screenY = dy + (y1 * r);
const screenW = (x2 - x1) * r;
const screenH = (y2 - y1) * r;
// 박스 DOM 생성
const boxDiv = document.createElement('div');
boxDiv.className = 'bbox';
// [스타일 적용] 동적 색상 및 위치
boxDiv.style.left = `${screenX}px`;
boxDiv.style.top = `${screenY}px`;
boxDiv.style.width = `${screenW}px`;
boxDiv.style.height = `${screenH}px`;
boxDiv.style.borderColor = boxColor; // 테두리 색상 변경
// 라벨 생성
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px'; // 박스 바로 위
label.style.left = '-2px'; // 테두리 두께 고려 정렬
label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
label.style.color = 'white'; // 글자는 흰색
label.style.fontSize = '12px';
label.style.fontWeight = 'bold';
label.style.padding = '2px 6px';
label.style.borderRadius = '3px';
label.style.whiteSpace = 'nowrap';
label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
// [요청 포맷 적용] Class 명 : Tag id
label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
boxDiv.appendChild(label);
bboxContainerEl.appendChild(boxDiv);
});
}
detListEl.textContent = outputLines.join("\n");
}
function updateBboxContainerPosition() {
if (!canvasEl || !bboxContainerEl) return;
bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
}
// 모델 변경 이벤트
if (modelContainerEl) {
modelContainerEl.addEventListener('change', (e) => {
if(e.target.name === 'current-model') {
updateModelDisplay();
fetch('/set-model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: e.target.value})
});
}
});
}
function updateModelDisplay() {
if(!modelContainerEl || !modelDisplayEl) return;
const checked = modelContainerEl.querySelector('input:checked');
if(checked) {
const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
modelDisplayEl.textContent = label ? label.textContent : checked.value;
}
}
const detPanelToggle = document.getElementById('det-panel-toggle');
const detWrap = document.getElementById('det-wrap');
if (detPanelToggle && detWrap) {
detPanelToggle.addEventListener('change', (e) => {
if (e.target.checked) {
// 체크됨 -> 패널 보이기 (기존 flex 속성 복구)
detWrap.classList.remove('collapsed-panel');
} else {
// 체크 해제 -> 패널 숨기기
detWrap.classList.add('collapsed-panel');
}
// 패널 크기가 변하므로 캔버스 내 BBox 위치 재계산 필요
// 약간의 지연 후 실행 (Flexbox 애니메이션 등이 없으므로 즉시 해도 되지만 안전하게)
setTimeout(() => {
if(typeof updateBboxContainerPosition === 'function') {
updateBboxContainerPosition();
}
}, 50);
});
}
// 초기 실행
updateModelDisplay();
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
loadPoiList();
loadModelList();
});

@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - AI Drone System</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<nav class="tabs">
<div>
<button id="tab-video" class="tab-button active">Video</button>
<button id="tab-models" class="tab-button">Settings</button>
</div>
<button id="logout-button" class="logout-button">Logout</button>
</nav>
</header>
<main>
<div id="content-video" class="tab-content active">
<div class="video-container">
<div class="video-player">
<div id="video-wrap">
<div id="info">
<span id="status" class="status">연결 시도 중...</span>
<span id="frame-info"></span>
<div class="panel-toggle-container">
<label class="switch">
<input type="checkbox" id="det-panel-toggle">
<span class="slider round"></span>
</label>
<span class="toggle-text">Detections</span>
</div>
</div>
<span id="current-model-display" class="video-overlay top-right hidden"></span>
<span id="fps-display" class="video-overlay bottom-right"></span>
<canvas id="frame"></canvas>
<div id="bbox-container"></div>
</div>
<div id="det-wrap" class="collapsed-panel">
<div id="det-title">Detections</div>
<div id="det-list"></div>
</div>
<div id="current-model-container" class="segmented-control-container">
<input type="radio" id="model-objdet" name="current-model" value="OBJDET" checked>
<label for="model-objdet" class="segmented-control-button">객체 탐지/분류</label>
<input type="radio" id="model-fire" name="current-model" value="FIRE">
<label for="model-fire" class="segmented-control-button">화재 감지</label>
<input type="radio" id="model-crowd" name="current-model" value="CROWD">
<label for="model-crowd" class="segmented-control-button">군중 위험 인식</label>
<input type="radio" id="model-deid" name="current-model" value="FACEATTR">
<label for="model-deid" class="segmented-control-button">얼굴/ 인상착의 인식</label>
<input type="radio" id="model-abnorm" name="current-model" value="ABNORM">
<label for="model-abnorm" class="segmented-control-button">이상행동 감지</label>
<input type="radio" id="model-lpr" name="current-model" value="LPR">
<label for="model-lpr" class="segmented-control-button">차량 번호판 인식</label>
<input type="radio" id="model-viptrack" name="current-model" value="VIPTRACK">
<label for="model-viptrack" class="segmented-control-button">관심인물추적</label>
</div>
</div>
</div>
</div>
<div id="content-models" class="tab-content">
<div class="toolbar">
<label class="section-title">AI 모델파일 관리</label>
<input type="file" id="global-file-input" class="hidden-file-input" accept=".aiwbin">
<label for="global-file-input" class="btn-browse">찾아보기</label>
<span id="file-name-display">선택된 파일 없음</span>
<button id="global-upload-button" class="btn-action">⬆️ 업로드</button>
<button class="refresh-button">🔄 새로고침</button>
</div>
<table class="model-table">
<thead>
<tr>
<th>번호</th>
<th>AI Model 역할</th>
<th>파일명</th>
<th>현재 버전</th>
<th>삭제</th>
</tr>
</thead>
<tbody>
<tr data-role="OBJDET">
<td>1</td>
<td>객체 탐지/ 분류</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FIRE">
<td>2</td>
<td>화재(불꽃, 연기) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="ABNORM">
<td>3</td>
<td>이상행동(쓰러짐, 폭행) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="FACEATTR">
<td>4</td>
<td>얼굴/ 인상착의 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<tr data-role="LPR">
<td>5</td>
<td>차량 번호판/ 차종 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
</tbody>
</table>
<div class="divider"></div>
<div class="toolbar">
<label class="section-title">관심 인물파일 관리</label>
<div class="right-actions">
<button id="btn-poi-register" class="btn-white">등록</button>
<button id="btn-poi-refresh" class="refresh-button">🔄 새로 고침</button>
</div>
</div>
<table class="model-table" id="poi-table">
<thead>
<tr>
<th style="width: 10%;">번호</th>
<th style="width: 30%;">관심인물명</th>
<th style="width: 30%;">미리 보기</th>
<th style="width: 30%;">파일 등록</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div id="poi-empty-msg" class="empty-message hidden">
등록된 관심 인물이 없습니다
</div>
</div>
</main>
<div id="poi-modal" class="modal hidden">
<div class="modal-content">
<h3>관심인물 등록</h3>
<div class="modal-body">
<input type="text" id="poi-name-input" placeholder="관심인물명(영문숫자만 가능)" maxlength="20">
</div>
<div class="modal-actions">
<button id="btn-modal-confirm" class="btn-modal-confirm">확인</button>
<button id="btn-modal-cancel" class="btn-modal-cancel">취소</button>
</div>
</div>
</div>
<div id="image-viewer-modal" class="modal hidden" style="background-color: rgba(0,0,0,0.9);">
<span class="close-viewer">&times;</span>
<img class="modal-image" id="full-image">
</div>
<script src="app.js"></script>
</body>
</html>

@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Drone System - Login</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="login-page">
<h1 class="page-title">AI Mission Camera Console for Drones</h1>
<div class="login-container">
<h1>Login</h1>
<p class="login-subtext">Sign In to your account</p>
<form id="login-form">
<div class="input-group">
<span class="icon">👤</span>
<input type="text" id="id" name="id" placeholder="Username" required>
</div>
<div class="input-group">
<span class="icon">🔒</span>
<input type="password" id="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="login-button">Login</button>
</form>
</div>
<div class="footer-bar">
<img src="science_dep.png" width="134" height="30"/>
<img src="kait_logo_on.png" width="128" height="40"/>
<img src="img_tta_logo.svg" width="140" height="40"/>
<img src="nexreal_logo-dark.svg" width="100" height="25"/>
<img src="cuuva_logo.png" width="66" height="30"/>
<img src="nextchip_logo.png" width="99" height="25"/>
<img src="datamaker_logo.svg" width="100" height="30"/>
</div>
<script>
// DOM(HTML)이 모두 로드되었을 때 실행
document.addEventListener('DOMContentLoaded', () => {
// ID가 'login-form'인 폼 요소를 가져옵니다.
const loginForm = document.getElementById('login-form');
if (loginForm) {
// 폼에서 'submit' 이벤트(로그인 버튼 클릭)가 발생했을 때 실행될 함수를 정의
loginForm.addEventListener('submit', (event) => {
// 1. 폼의 기본 제출 동작(페이지 새로고침 또는 이동)을 막습니다.
event.preventDefault();
// 2. ID와 비밀번호 입력 필드에서 사용자가 입력한 값을 가져옵니다.
const idInput = document.getElementById('id');
const passwordInput = document.getElementById('password');
const id = idInput.value;
const password = passwordInput.value;
// 3. ID와 비밀번호가 'admin'인지 확인합니다.
if (id === 'admin' && password === 'admin') {
// 4. 일치하면: 'dashboard.html' 페이지로 이동합니다.
console.log('Login successful. Redirecting to dashboard.html...');
window.location.href = 'dashboard.html';
} else {
// 5. 일치하지 않으면: 사용자에게 알림 창을 표시합니다.
alert('ID 또는 비밀번호가 올바지 않습니다.');
// (선택 사항) 틀린 비밀번호 필드를 비우고 다시 포커스합니다.
passwordInput.value = '';
passwordInput.focus();
}
});
}
});
</script>
</body>
</html>

@ -0,0 +1,431 @@
/* style.css */
/* 기본 및 공통 스타일 */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
}
/* 버튼 기본 스타일 reset 및 공통 */
button {
cursor: pointer;
border-radius: 4px;
border: 1px solid #555;
background-color: #3a3a3a;
color: #e0e0e0;
padding: 5px 10px;
}
/* 로그인 페이지 등 배경 스타일 */
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
/* 배경 이미지 설정 (전체 화면 꽉 채우기) */
background: url('drone_background.png') no-repeat center center fixed;
background-size: cover;
position: relative; /* page-title 절대 위치의 기준점 */
}
/* [수정됨] 로그인 화면 메인 타이틀 스타일 */
.page-title {
position: absolute;
top: 60px; /* 상단 여백 */
left: 60px; /* 좌측 여백 */
color: #ffffff; /* 흰색 텍스트 */
font-size: 3rem; /* 크기 2배 (약 64px ~ 70px) */
font-weight: 600;
margin: 0;
z-index: 10;
/* 배경과 구분을 위한 검은 그림자 */
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
}
.login-container {
background: rgba(255, 255, 255, 0.95); /* 배경이 비치지 않도록 불투명도 조정 */
padding: 2rem 3rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* 그림자 조금 더 진하게 */
width: 400px;
box-sizing: border-box;
z-index: 20;
}
.login-container h1 {
font-size: 2.2rem;
color: #333;
margin-bottom: 0.5rem;
font-weight: bold;
text-align: center; /* 로그인 박스 내부 타이틀 중앙 정렬 */
}
.login-subtext {
font-size: 1rem;
color: #666;
margin: 0 0 2.5rem 0;
text-align: center; /* 서브 텍스트 중앙 정렬 */
}
.input-group {
margin-bottom: 1rem;
position: relative;
text-align: left;
}
.input-group input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
background-color: #fff;
color: #333;
font-size: 1rem;
}
.input-group .icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
}
/* [수정됨] 로그인 버튼 스타일 (중앙 정렬 추가) */
.login-button {
width: auto;
min-width: 150px;
padding: 0.8rem 2.5rem;
background-color: #5a67d8;
color: white;
border: none;
font-size: 1rem;
font-weight: bold;
/* 중앙 정렬을 위한 설정 */
display: block;
margin: 1.5rem auto 0 auto;
}
.login-button:hover { background-color: #434190; }
.footer-bar {
position: absolute; bottom: 0; left: 0; width: 100%; height: 50px;
background-color: #ffffff; opacity: 0.7; z-index: 100;
display: flex; justify-content: center; align-items: center; gap: 20px;
}
/* 대시보드 헤더 */
header {
background-color: #333;
padding: 0 1rem;
flex-shrink: 0;
}
.tabs { display: flex; justify-content: space-between; align-items: center; }
.tab-button {
background: none; border: none; color: #aaa;
padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold;
}
.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; }
.logout-button {
background-color: #d9534f; color: white; border: none;
padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold;
}
/* 메인 컨텐츠 (다크 테마) */
main {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0;
background-color: #1a1a1a;
color: #e0e0e0;
}
.tab-content { display: none; }
.tab-content.active {
display: flex; flex-direction: column; flex-grow: 1; min-height: 0;
}
/* Video 스타일 */
.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; }
.video-player {
width: 100%; flex-grow: 1; min-height: 0;
background-color: #111; color: #eee;
display: flex; flex-direction: row; gap: 10px;
padding: 10px; box-sizing: border-box; border-radius: 4px; position: relative;
}
#video-wrap { flex: 2; display: flex; flex-direction: column; gap: 4px; position: relative; }
#frame { flex: 1; min-height: 0; max-width: 100%; background: #000; border: 1px solid #333; z-index: 1; }
#det-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; z-index: 1; }
#det-list { flex: 1; font-size: 11px; background: #000; border: 1px solid #333; padding: 6px; overflow-y: auto; white-space: pre; }
#bbox-container { position: absolute; pointer-events: none; z-index: 5; top: 0; left: 0; }
.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
/* Segmented Control (모델 선택) */
.segmented-control-container {
position: absolute; top: 60px; left: 20px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 4px;
background-color: rgba(50, 50, 50, 0.7); padding: 4px; border-radius: 6px;
}
.segmented-control-container input[type="radio"] { display: none; }
.segmented-control-button {
display: inline-block; padding: 6px 10px; font-size: 0.85rem;
color: #eee; background-color: #555; border: 1px solid #777; border-radius: 4px;
cursor: pointer; user-select: none;
}
.segmented-control-container input[type="radio"]:checked + .segmented-control-button {
background-color: #5a67d8; color: white; border-color: #434190; font-weight: bold;
}
/* Video Overlays */
.video-overlay {
position: absolute; z-index: 10;
background-color: rgba(0, 0, 0, 0.5); color: #ffffff;
padding: 4px 8px; border-radius: 4px; font-weight: bold; pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.video-overlay.top-right { top: 100px; right: 10px; font-size: 60px; }
.video-overlay.bottom-right { bottom: 70px; right: 10px; font-size: 28px; color: #FFEE00; }
/* ========================================================== */
/* [설정 탭 - AI 모델 & 관심 인물 관리 스타일] */
/* ========================================================== */
.toolbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 1rem;
}
.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; }
.right-actions { margin-left: auto; display: flex; gap: 10px; }
/* 공통 테이블 스타일 */
.model-table {
width: 100%; border-collapse: collapse; background: #2a2a2a;
}
.model-table th, .model-table td {
border: 1px solid #444; padding: 0.8rem 1rem; vertical-align: middle;
}
.model-table th { background-color: #333; text-align: center; }
.model-table td:first-child { text-align: center; } /* 번호 */
.model-table td { text-align: center; } /* 기본 중앙 정렬 */
/* 파일명 표시 input */
#file-name-display {
font-size: 0.9rem; color: #ccc; background-color: #2a2a2a;
border: 1px solid #444; padding: 5px 10px; border-radius: 4px;
min-width: 300px; text-align: left; font-style: italic;
}
.hidden-file-input { display: none; }
.hidden { display: none !important; }
/* 버튼 스타일 */
.btn-browse {
cursor: pointer; border-radius: 4px; border: 1px solid #666;
padding: 5px 10px; font-size: 14px; background-color: #4f4f4f; color: #e0e0e0;
}
.btn-browse:hover { background-color: #5a5a5a; }
.btn-action { background-color: #4CAF50; color: white; border: none; }
.btn-delete { background-color: #f44336; color: white; border: none; }
.refresh-button { margin-left: auto; }
/* 흰색 배경 버튼 (UI 디자인 반영) */
.btn-white {
background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold;
}
.btn-white:hover { background-color: #f0f0f0; }
/* 구분선 */
.divider {
height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none;
}
/* ========================================================== */
/* [신규] 관심 인물(POI) 관리 전용 스타일 */
/* ========================================================== */
/* 빈 목록 메시지 */
.empty-message {
text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem;
}
/* 미리보기 컨테이너 (세로 정렬) */
.preview-container {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
/* 개별 이미지 아이템 (이미지 + 삭제버튼) */
.preview-item {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 4px;
}
/* 미리보기 이미지 크기 */
.poi-preview {
width: 50px; height: 50px; object-fit: cover;
border-radius: 4px; border: 1px solid #555; display: block; margin: 0 auto;
cursor: pointer; /* 클릭 가능 표시 */
transition: 0.3s;
}
.poi-preview:hover { opacity: 0.7; }
/* 삭제 버튼 공통 */
.btn-delete-small {
background-color: #fff; border: 1px solid #ccc; color: #333;
padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px;
border-radius: 2px;
}
.btn-delete-small:hover { background-color: #eee; }
/* 모달 관련 스타일 (기존 유지) */
.modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex; justify-content: center; align-items: center; z-index: 2000;
}
.modal-content {
background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333;
}
.modal-content h3 { margin-top: 0; margin-bottom: 2rem; text-align: left; font-size: 1.2rem; font-weight: bold; }
.modal-body { margin-bottom: 2rem; }
.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; text-align: center; box-sizing: border-box; }
.modal-actions { display: flex; justify-content: center; gap: 20px; }
.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; }
.btn-modal-confirm { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-cancel { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-confirm:hover, .btn-modal-cancel:hover { background-color: #eee; }
/* ========================================================== */
/* [신규] 이미지 뷰어 모달 스타일 */
/* ========================================================== */
/* 뷰어 모달 이미지 */
.modal-image {
margin: auto;
display: block;
max-width: 90%;
max-height: 90%;
border-radius: 5px;
animation-name: zoom;
animation-duration: 0.4s;
}
/* 확대 애니메이션 */
@keyframes zoom {
from {transform:scale(0)}
to {transform:scale(1)}
}
/* 닫기 버튼 (X) */
.close-viewer {
position: absolute;
top: 20px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.close-viewer:hover,
.close-viewer:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
#info {
display: flex;
align-items: center;
gap: 15px;
background-color: rgba(0, 0, 0, 0.6); /* 가독성을 위한 반투명 배경 */
padding: 8px 12px;
border-radius: 4px;
position: absolute;
top: 10px;
left: 10px;
z-index: 20; /* 오버레이보다 위 */
color: #fff;
font-size: 14px;
}
/* [추가됨] 토글 스위치 컨테이너 */
.panel-toggle-container {
display: flex;
align-items: center;
gap: 8px;
margin-left: 10px;
border-left: 1px solid #555;
padding-left: 15px;
}
.toggle-text {
font-size: 0.85rem;
color: #ddd;
font-weight: bold;
}
/* [추가됨] 스위치(Checkbox) 디자인 */
.switch {
position: relative;
display: inline-block;
width: 34px;
height: 20px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 34px;
}
.slider:before {
position: absolute;
content: "";
height: 14px;
width: 14px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
/* 체크되었을 때 색상 (기존 버튼 색상과 통일) */
input:checked + .slider {
background-color: #5a67d8;
}
input:checked + .slider:before {
transform: translateX(14px);
}
/* [추가됨] 패널 숨김 처리용 클래스 */
/* !important를 사용하여 flex 속성을 덮어씌웁니다 */
.collapsed-panel {
display: none !important;
}
/*2025.11.25 16:16*/

@ -9,7 +9,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
if (confirm('정말로 로그아웃 하시겠습니까?')) {
// 로그인 페이지(루트)로 이동
window.location.href = '/';
}
});
@ -29,6 +28,7 @@ document.addEventListener('DOMContentLoaded', () => {
tabModels.classList.remove('active');
contentVideo.classList.add('active');
contentModels.classList.remove('active');
updateBboxContainerPosition();
});
tabModels.addEventListener('click', () => {
@ -42,7 +42,284 @@ document.addEventListener('DOMContentLoaded', () => {
}
// =================================================
// 2. AI 모델 관리 로직
// [신규] Mission UI 로직
// =================================================
const MODEL_DEFINITIONS = {
'OBJDET': { name: "객체 탐지", classes: ["person", "car", "van", "truck", "bus", "motor"] },
'FIRE': { name: "화재 감지", classes: ["flame", "smoke"] },
'CROWD': { name: "군중 위험", classes: ["person", "crowd"] },
'FACEATTR': { name: "얼굴 인식", classes: ["face"] },
'ABNORM': { name: "이상 행동", classes: ["fall", "fight"] },
'LPR': { name: "차량 인식", classes: ["plate"] },
'VIPTRACK': { name: "관심 인물", classes: ["person"] }
};
let currentModelCode = 'OBJDET';
let activeClassFilters = new Set();
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', () => {
navItems.forEach(nav => nav.classList.remove('active'));
item.classList.add('active');
const modelCode = item.dataset.model;
if(modelCode) {
currentModelCode = modelCode;
changeModel(modelCode);
updateFilterBar(modelCode);
}
});
});
function changeModel(modelCode) {
fetch('/set-model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: modelCode})
}).then(r => console.log(`Model changed to ${modelCode}`))
.catch(e => console.error(e));
}
const filterBar = document.getElementById('filter-bar');
function updateFilterBar(modelCode) {
if(!filterBar) return;
filterBar.innerHTML = '';
const def = MODEL_DEFINITIONS[modelCode];
if(!def) return;
const allLabel = document.createElement('label');
allLabel.className = 'filter-check';
const allInput = document.createElement('input');
allInput.type = 'checkbox';
allInput.checked = true; // 기본적으로 All은 체크됨
allLabel.appendChild(allInput);
allLabel.append(' all');
const sep = document.createElement('span');
sep.className = 'v-line';
sep.innerText = '|';
filterBar.appendChild(allLabel);
filterBar.appendChild(sep);
const classInputs = [];
def.classes.forEach(cls => {
const label = document.createElement('label');
label.className = 'filter-check';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true; // All이 체크되어 있으므로 기본적으로 모두 체크
input.dataset.cls = cls;
label.appendChild(input);
label.append(` ${cls}`);
filterBar.appendChild(label);
classInputs.push(input);
// 개별 체크박스 변경 시 로직
input.addEventListener('change', () => {
// 하나라도 꺼지면 All 체크 해제, 모두 켜지면 All 체크
const allChecked = classInputs.every(i => i.checked);
allInput.checked = allChecked;
updateActiveFilters(classInputs);
});
});
// All 체크박스 변경 시 로직
allInput.addEventListener('change', () => {
const isChecked = allInput.checked;
classInputs.forEach(inp => inp.checked = isChecked);
updateActiveFilters(classInputs);
});
// 초기 필터 상태 반영
updateActiveFilters(classInputs);
}
function updateActiveFilters(classInputs) {
activeClassFilters.clear();
// 체크된 항목들만 Set에 추가
classInputs.forEach(inp => {
if(inp.checked) activeClassFilters.add(inp.dataset.cls);
});
}
updateFilterBar('OBJDET');
// =================================================
// 2. 비디오 & 웹소켓 & Summary 패널
// =================================================
const LABEL_MAP = {
1: { tagName: "객체 탐지", classes: { 0: "person", 1: "car", 2: "van", 3: "truck", 4: "bus", 5: "motor" } },
2: { tagName: "화재 인식", classes: { 0: "flame", 1: "smoke" } },
3: { tagName: "얼굴 인식", classes: { 0: "face" } },
4: { tagName: "차량번호", classes: { 0: "plate" } }
};
function getBoxColor(clsName) {
const colorMap = {
'person': '#00FF00', 'car': '#00FFFF', 'van': '#FFA500',
'bus': '#9370DB', 'truck': '#FF69B4', 'flame': '#FF0000', 'smoke': '#CCC'
};
return colorMap[clsName] || '#FFFFFF';
}
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const bboxContainerEl = document.getElementById("bbox-container");
const fpsDisplayEl = document.getElementById("fps-display");
const resolutionEl = document.getElementById("resolution-display");
const connMsgEl = document.getElementById("connection-msg");
const summaryListEl = document.getElementById("summary-list");
let ctx = null;
if (canvasEl) ctx = canvasEl.getContext('2d');
let frameCount = 0;
let lastFpsCheckTime = performance.now();
let lastFrameMeta = null;
let viewConfig = { r: 1, dx: 0, dy: 0 };
function connect() {
const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer";
ws.onopen = () => { if(connMsgEl) connMsgEl.textContent = "영상 수신 중"; };
ws.onclose = () => {
if(connMsgEl) connMsgEl.textContent = "연결 끊김 (재접속 중...)";
setTimeout(connect, 2000);
};
ws.onmessage = (event) => {
const data = event.data;
if (typeof data === "string") {
try {
const meta = JSON.parse(data);
if (meta.type === "frame") {
lastFrameMeta = meta;
showDetections(meta);
}
} catch(e){ console.error(e); }
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
if(fpsDisplayEl) fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
frameCount = 0;
lastFpsCheckTime = now;
}
const blob = new Blob([data], {type: "image/jpeg"});
createImageBitmap(blob).then(bmp => {
if (resolutionEl) {
resolutionEl.textContent = `${bmp.width}x${bmp.height}`;
}
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
const dw = bmp.width * r;
const dh = bmp.height * r;
const dx = (canvasEl.width - dw) / 2;
const dy = (canvasEl.height - dh) / 2;
viewConfig = { r, dx, dy };
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
ctx.drawImage(bmp, dx, dy, dw, dh);
bmp.close();
updateBboxContainerPosition();
});
}
};
}
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const items = meta.items || [];
const currentCounts = {};
items.forEach((it) => {
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId] && LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
// [수정] 필터링 로직 강화: Set에 없으면 절대 그리지 않음 (All 체크 로직과 연동)
if (!activeClassFilters.has(displayClassName)) {
return;
}
if(!currentCounts[displayClassName]) currentCounts[displayClassName] = 0;
currentCounts[displayClassName]++;
const x1 = it.x1 || 0; const y1 = it.y1 || 0;
const x2 = it.x2 || 0; const y2 = it.y2 || 0;
const boxColor = getBoxColor(displayClassName);
const { r, dx, dy } = viewConfig;
const screenX = dx + (x1 * r);
const screenY = dy + (y1 * r);
const screenW = (x2 - x1) * r;
const screenH = (y2 - y1) * r;
const boxDiv = document.createElement('div');
boxDiv.className = 'bbox';
boxDiv.style.left = `${screenX}px`;
boxDiv.style.top = `${screenY}px`;
boxDiv.style.width = `${screenW}px`;
boxDiv.style.height = `${screenH}px`;
boxDiv.style.borderColor = boxColor;
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px';
label.style.left = '-2px';
label.style.backgroundColor = boxColor;
label.style.color = '#000';
label.style.fontSize = '11px';
label.style.fontWeight = 'bold';
label.style.padding = '1px 4px';
label.textContent = displayClassName;
boxDiv.appendChild(label);
bboxContainerEl.appendChild(boxDiv);
});
updateSummaryPanel(currentCounts);
}
function updateSummaryPanel(counts) {
if(!summaryListEl) return;
summaryListEl.innerHTML = '';
const keys = Object.keys(counts);
if (keys.length === 0) {
summaryListEl.innerHTML = '<div style="color:#777; text-align:center; padding:10px;">No detection</div>';
return;
}
keys.forEach(key => {
const row = document.createElement('div');
row.className = 'summary-row';
row.innerHTML = `<span class="label">${key}</span><span class="count">${counts[key]}</span>`;
summaryListEl.appendChild(row);
});
}
function updateBboxContainerPosition() {
if (!canvasEl || !bboxContainerEl) return;
bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
}
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
// =================================================
// 3. 모델 파일 관리 로직
// =================================================
function loadModelList() {
const tableBody = document.querySelector('.model-table tbody');
@ -110,70 +387,44 @@ document.addEventListener('DOMContentLoaded', () => {
// =================================================
// 3. 관심 인물(POI) 관리 로직
// 4. 관심 인물(POI) 관리 로직
// =================================================
const poiTableBody = document.querySelector('#poi-table tbody');
const poiEmptyMsg = document.getElementById('poi-empty-msg');
const btnPoiRegister = document.getElementById('btn-poi-register');
const btnPoiRefresh = document.getElementById('btn-poi-refresh');
// 모달 및 뷰어 요소
const poiModal = document.getElementById('poi-modal');
const poiNameInput = document.getElementById('poi-name-input');
const btnModalConfirm = document.getElementById('btn-modal-confirm');
const btnModalCancel = document.getElementById('btn-modal-cancel');
const imageViewerModal = document.getElementById('image-viewer-modal');
const fullImage = document.getElementById('full-image');
const closeViewerBtn = document.querySelector('.close-viewer');
// POI 목록 로드
function loadPoiList() {
if (!poiTableBody) return;
fetch('/poi/list')
.then(res => res.json())
.then(list => {
poiTableBody.innerHTML = '';
if (!list || list.length === 0) {
poiEmptyMsg.classList.remove('hidden');
poiTableBody.parentElement.classList.add('hidden');
} else {
poiEmptyMsg.classList.add('hidden');
poiTableBody.parentElement.classList.remove('hidden');
list.forEach((item, index) => {
const tr = document.createElement('tr');
let previewHtml = '-';
if (item.images && item.images.length > 0) {
const imagesHtml = item.images.map(imgName => {
const imgSrc = `/poi-images/${item.name}/${imgName}`;
return `
<div class="preview-item">
<img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')" title="클릭하여 확대">
<button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button>
</div>
`;
return `<div class="preview-item"><img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')"><button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button></div>`;
}).join('');
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
}
const fileInputId = `poi-file-${item.name}`;
tr.innerHTML = `
<td>${index + 1}</td>
<td>
<span style="font-weight:bold; font-size:1.1rem; margin-right:10px;">${item.name}</span>
<button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button>
</td>
<td>${previewHtml}</td>
<td>
<input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)">
<label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333; border:1px solid #ccc;">찾아보기</label>
</td>
`;
tr.innerHTML = `<td>${index + 1}</td><td><span style="font-weight:bold; margin-right:10px;">${item.name}</span><button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button></td><td>${previewHtml}</td><td><input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)"><label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333;">찾아보기</label></td>`;
poiTableBody.appendChild(tr);
});
}
@ -181,407 +432,44 @@ document.addEventListener('DOMContentLoaded', () => {
.catch(err => console.error('POI Load Error:', err));
}
// [전역 함수] 이미지 뷰어 열기
window.openImageViewer = function(src) {
if (fullImage && imageViewerModal) {
fullImage.src = src;
imageViewerModal.classList.remove('hidden');
}
};
// 뷰어 닫기 로직
if (closeViewerBtn) {
closeViewerBtn.addEventListener('click', () => {
imageViewerModal.classList.add('hidden');
});
closeViewerBtn.addEventListener('click', () => { imageViewerModal.classList.add('hidden'); });
}
window.addEventListener('click', (e) => {
if (e.target === imageViewerModal) {
imageViewerModal.classList.add('hidden');
}
if (e.target === imageViewerModal) { imageViewerModal.classList.add('hidden'); }
});
// [전역 함수] 인물 전체 삭제
window.deletePoi = function(name) {
if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return;
fetch('/poi/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('삭제 실패');
});
if (!confirm(`관심인물 '${name}' 삭제?`)) return;
fetch('/poi/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(res => res.json()).then(data => { if (data.status === 'success') loadPoiList(); });
};
// [전역 함수] 특정 이미지만 삭제
window.deletePoiImage = function(name, imageName) {
if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return;
fetch('/poi/delete-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, imageName })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('이미지 삭제 실패');
});
if (!confirm(`이미지 삭제?`)) return;
fetch('/poi/delete-image', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, imageName }) }).then(res => res.json()).then(data => { if (data.status === 'success') loadPoiList(); });
};
// 다중 파일 업로드 처리
window.uploadPoiImage = function(name, input) {
if (!input.files || input.files.length === 0) return;
const formData = new FormData();
formData.append('poiName', name);
for (let i = 0; i < input.files.length; i++) {
const file = input.files[i];
const ext = file.name.split('.').pop().toLowerCase();
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
input.value = '';
return;
}
formData.append('poiFile', file);
}
fetch('/poi/upload-image', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('이미지 등록 완료');
loadPoiList();
} else {
alert('업로드 실패: ' + (data.message || '오류'));
}
})
.catch(() => alert('오류 발생'));
for (let i = 0; i < input.files.length; i++) { formData.append('poiFile', input.files[i]); }
fetch('/poi/upload-image', { method: 'POST', body: formData }).then(res => res.json()).then(data => { if (data.status === 'success') { alert('완료'); loadPoiList(); } else { alert('실패'); } });
input.value = '';
};
// 이벤트 리스너
if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
if (btnPoiRegister) {
btnPoiRegister.addEventListener('click', () => {
poiNameInput.value = '';
poiModal.classList.remove('hidden');
});
}
if (btnModalCancel) {
btnModalCancel.addEventListener('click', () => {
poiModal.classList.add('hidden');
});
}
if (btnPoiRegister) { btnPoiRegister.addEventListener('click', () => { poiNameInput.value = ''; poiModal.classList.remove('hidden'); }); }
if (btnModalCancel) { btnModalCancel.addEventListener('click', () => { poiModal.classList.add('hidden'); }); }
if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim();
if (!name) return alert('이름을 입력하세요.');
const regex = /^[a-zA-Z0-9]+$/;
if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
fetch('/poi/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
poiModal.classList.add('hidden');
loadPoiList();
} else {
alert(data.message || '등록 실패');
}
});
});
}
// =================================================
// 4. 비디오 & 웹소켓 (라벨 매핑 및 컬러 로직 추가)
// =================================================
// [설정] Tag/Class 매핑 테이블
const LABEL_MAP = {
1: {
tagName: "객체 탐지/ 분류",
classes: {
0: "person",
1: "car",
2: "van",
3: "truck",
4: "bus",
5: "motor"
}
},
2: {
tagName: "화재 인식",
classes: {
0: "flame",
1: "smoke"
}
},
3: {
tagName: "얼굴 인식",
classes: {
0: "face"
}
},
4: {
tagName: "차량번호판 및 차종 인식",
classes: {
0: "License plate"
}
}
};
// [설정] 박스 색상 생성 함수
function getBoxColor(tag, cls) {
// Tag 2: 화재 관련 (긴급한 색상)
if (tag === 2) {
if (cls === 0) return '#FF0000'; // Flame: 빨강
if (cls === 1) return '#808080'; // Smoke: 회색
}
// Tag 1: 객체 탐지 (다양한 색상)
if (tag === 1) {
const colors = [
'#00FF00', // Person: 라임 그린
'#00FFFF', // Car: 시안
'#FFA500', // Van: 오렌지
'#FF69B4', // Truck: 핫핑크
'#9370DB', // Bus: 미디엄 퍼플
'#FFD700' // Motor: 골드
];
return colors[cls % colors.length] || '#FFFFFF';
}
// Tag 3: 얼굴
if (tag === 3) return '#1E90FF'; // 도저 블루
// Tag 4: 번호판
if (tag === 4) return '#32CD32'; // 라임 그린
// 기타 기본값
return '#FF0000';
}
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const statusEl = document.getElementById("status");
const detListEl = document.getElementById("det-list");
const bboxContainerEl = document.getElementById("bbox-container");
const modelContainerEl = document.getElementById("current-model-container");
const modelDisplayEl = document.getElementById("current-model-display");
const fpsDisplayEl = document.getElementById("fps-display");
let ctx = null;
if (canvasEl) ctx = canvasEl.getContext('2d');
let frameCount = 0;
let lastFpsCheckTime = performance.now();
let lastFrameMeta = null;
let viewConfig = { r: 1, dx: 0, dy: 0 };
function connect() {
const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
statusEl.textContent = "연결됨";
statusEl.className = "status";
};
ws.onclose = () => {
statusEl.textContent = "연결 종료. 재접속...";
statusEl.className = "status err";
setTimeout(connect, 2000);
};
ws.onmessage = (event) => {
const data = event.data;
// 1. 텍스트 데이터(JSON) 처리
if (typeof data === "string") {
console.log("-----" + data); // 필요시 주석 해제
try {
const meta = JSON.parse(data);
if (meta.type === "frame") {
lastFrameMeta = meta;
document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
showDetections(meta);
}
} catch(e){ console.error(e); }
// 2. 바이너리 데이터(영상 프레임) 처리
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
frameCount = 0;
lastFpsCheckTime = now;
}
const blob = new Blob([data], {type: "image/jpeg"});
createImageBitmap(blob).then(bmp => {
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
const dw = bmp.width * r;
const dh = bmp.height * r;
const dx = (canvasEl.width - dw) / 2;
const dy = (canvasEl.height - dh) / 2;
viewConfig = { r, dx, dy };
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
ctx.drawImage(bmp, dx, dy, dw, dh);
bmp.close();
updateBboxContainerPosition();
});
}
};
}
/**
* [수정됨] 탐지 정보를 받아 화면에 Bounding Box를 그리는 함수
* - 매핑 테이블을 사용하여 Tag/Class 이름을 표시
* - Class별 색상 구분 적용
*/
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const ch = meta.ch !== undefined ? meta.ch : '-';
const seq = meta.seq !== undefined ? meta.seq : '-';
const ts = meta.ts_us !== undefined ? meta.ts_us : '-';
let outputLines = [`[META] CH:${ch} | SEQ:${seq} | TS:${ts}`];
const items = meta.items || [];
if (items.length === 0) {
outputLines.push("No detections");
} else {
items.forEach((it, i) => {
const x1 = it.x1 || 0;
const y1 = it.y1 || 0;
const x2 = it.x2 || 0;
const y2 = it.y2 || 0;
// 데이터에서 tag와 cls 추출
const tagId = it.tag !== undefined ? it.tag : -1;
const clsId = it.cls !== undefined ? it.cls : -1;
const tId = it.tid !== undefined ? it.tid : -1;
// 1. 이름 매핑 Lookup
let displayTagName = `Tag ${tagId}`;
let displayClassName = `Cls ${clsId}`;
if (LABEL_MAP[tagId]) {
displayTagName = LABEL_MAP[tagId].tagName;
if (LABEL_MAP[tagId].classes[clsId]) {
displayClassName = LABEL_MAP[tagId].classes[clsId];
}
}
// 2. 색상 결정
const boxColor = getBoxColor(tagId, clsId);
// 로그창 출력 (원본 데이터 유지)
outputLines.push(`#${i} | ${displayClassName} (${clsId}) | ${displayTagName} (${tagId}) | Box:[${x1.toFixed(0)},${y1.toFixed(0)}]`);
// 좌표 변환
const { r, dx, dy } = viewConfig;
const screenX = dx + (x1 * r);
const screenY = dy + (y1 * r);
const screenW = (x2 - x1) * r;
const screenH = (y2 - y1) * r;
// 박스 DOM 생성
const boxDiv = document.createElement('div');
boxDiv.className = 'bbox';
// [스타일 적용] 동적 색상 및 위치
boxDiv.style.left = `${screenX}px`;
boxDiv.style.top = `${screenY}px`;
boxDiv.style.width = `${screenW}px`;
boxDiv.style.height = `${screenH}px`;
boxDiv.style.borderColor = boxColor; // 테두리 색상 변경
// 라벨 생성
const label = document.createElement('div');
label.style.position = 'absolute';
label.style.top = '-20px'; // 박스 바로 위
label.style.left = '-2px'; // 테두리 두께 고려 정렬
label.style.backgroundColor = boxColor; // 배경색을 박스색과 동일하게
label.style.color = 'white'; // 글자는 흰색
label.style.fontSize = '12px';
label.style.fontWeight = 'bold';
label.style.padding = '2px 6px';
label.style.borderRadius = '3px';
label.style.whiteSpace = 'nowrap';
label.style.textShadow = '0px 0px 2px #000'; // 가독성을 위한 그림자
// [요청 포맷 적용] Class 명 : Tag id
label.textContent = tId === -1 || tId === 65535 ? `${displayClassName}` : `${displayClassName} : ${tId}`;
boxDiv.appendChild(label);
bboxContainerEl.appendChild(boxDiv);
});
}
detListEl.textContent = outputLines.join("\n");
}
function updateBboxContainerPosition() {
if (!canvasEl || !bboxContainerEl) return;
bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
}
// 모델 변경 이벤트
if (modelContainerEl) {
modelContainerEl.addEventListener('change', (e) => {
if(e.target.name === 'current-model') {
updateModelDisplay();
fetch('/set-model', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: e.target.value})
});
}
if (!name) return alert('이름 입력');
fetch('/poi/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(res => res.json()).then(data => { if (data.status === 'success') { poiModal.classList.add('hidden'); loadPoiList(); } else { alert('실패'); } });
});
}
function updateModelDisplay() {
if(!modelContainerEl || !modelDisplayEl) return;
const checked = modelContainerEl.querySelector('input:checked');
if(checked) {
const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
modelDisplayEl.textContent = label ? label.textContent : checked.value;
}
}
// 초기 실행
updateModelDisplay();
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
loadPoiList();
loadModelList();
});

@ -9,57 +9,103 @@
<body>
<header>
<nav class="tabs">
<div>
<button id="tab-video" class="tab-button active">Video</button>
<div style="display:flex; align-items:center;">
<span style="color:#fff; font-weight:bold; margin-right:20px; font-size:1.2rem;">AI Mission Camera Console</span>
<button id="tab-video" class="tab-button active">Mission View</button>
<button id="tab-models" class="tab-button">Settings</button>
</div>
<button id="logout-button" class="logout-button">Logout</button>
</nav>
</header>
<main>
<div id="content-video" class="tab-content active">
<div class="video-container">
<div class="video-player">
<div id="video-wrap">
<div id="info">
<span id="status" class="status">연결 시도 중...</span>
<span id="frame-info"></span>
<div id="content-video" class="tab-content active" style="padding:0; background:#333;">
<div class="mission-container">
<aside class="sidebar-nav">
<div class="nav-item active" data-model="OBJDET">
<div class="nav-icon">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px"><path fill="currentColor" d="M80-140v-320h320v320H80Zm80-80h160v-160H160v160Zm60-340 220-360 220 360H220Zm142-80h156l-78-126-78 126ZM863-42 757-148q-21 14-45.5 21t-51.5 7q-75 0-127.5-52.5T480-300q0-75 52.5-127.5T660-480q75 0 127.5 52.5T840-300q0 26-7 50.5T813-204L919-98l-56 56ZM660-200q42 0 71-29t29-71q0-42-29-71t-71-29q-42 0-71 29t-29 71q0 42 29 71t71 29ZM320-380Zm120-260Z"/></svg>
</div>
<span id="current-model-display" class="video-overlay top-right hidden"></span>
<span id="fps-display" class="video-overlay bottom-right"></span>
<canvas id="frame"></canvas>
<div id="bbox-container"></div>
<span>객체 탐지</span>
</div>
<div id="det-wrap">
<div id="det-title">Detections</div>
<div id="det-list"></div>
<div class="nav-item" data-model="FIRE">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M19.48 12.35c-1.57-4.08-7.16-4.3-5.81-10.23c.1-.44-.37-.78-.75-.55C9.29 3.71 6.68 8 8.87 13.62c.18.46-.36.89-.75.59c-1.81-1.37-2-3.34-1.84-4.75c.06-.52-.62-.77-.91-.34C4.69 10.16 4 11.84 4 14.37c.38 5.6 5.11 7.32 6.81 7.54c2.43.31 5.06-.14 6.95-1.87c2.98-2.73 2.82-6.21 1.72-7.69z"/></svg>
</div>
<span>화재 감지</span>
</div>
<div class="nav-item" data-model="CROWD">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM5.8 14h12.4c1.3 0 2.4-.8 2.8-2L23 6l-1.7-.5l-1.2 3.6l-1.3-1c.2-.5.2-1.1.2-1.7c0-2.8-2.2-5-5-5S9 3.6 9 6.4c0 .5.1 1.1.2 1.6l-1.2 1l-1.3-3.7L5 5.8l2 6.1c.4 1.3 1.5 2.1 2.8 2.1zM11 16v6h2v-6h-2z"/></svg>
</div>
<span>군중 위험</span>
</div>
<div class="nav-item" data-model="FACEATTR">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M9 11.75c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zm6 0c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25s1.25-.56 1.25-1.25s-.56-1.25-1.25-1.25zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8c0-.29.02-.58.05-.86c2.36-1.05 4.23-2.98 5.21-5.37c1.12 2.66 3.04 4.81 5.48 6.02c.09.28.16.57.16.87c-2.9 0-5.51 1.54-7.02 3.86c1.23.94 2.76 1.48 4.42 1.48z"/></svg>
</div>
<span>얼굴 인식</span>
</div>
<div class="nav-item" data-model="ABNORM">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8l2.1 2v6h2v-7.5l-2.1-2l.6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1c-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7z"/></svg>
<span style="position:absolute; right:10px; top:10px; font-weight:bold;">?</span>
</div>
<span>이상 행동</span>
</div>
<div class="nav-item" data-model="LPR">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.85 7h10.29l1.08 3.11H5.77L6.85 7zM19 17H5v-5h14v5z"/><circle cx="7.5" cy="14.5" r="1.5"/><circle cx="16.5" cy="14.5" r="1.5"/></svg>
</div>
<span>차량 인식</span>
</div>
<div class="nav-item" data-model="VIPTRACK">
<div class="nav-icon">
<svg viewBox="0 0 24 24"><path fill="currentColor" d="M5 5h4V3H3v6h2V5zm10-2v2h4v4h2V3h-6zm4 14h-4v2h6v-6h-2v4zM5 19v-4H3v6h6v-2H5zM12 7a5 5 0 1 0 5 5a5 5 0 0 0-5-5zm0 8a3 3 0 1 1 3-3a3 3 0 0 1-3 3z"/></svg>
</div>
<span>관심 인물</span>
</div>
</aside>
<div id="current-model-container" class="segmented-control-container">
<input type="radio" id="model-objdet" name="current-model" value="OBJDET" checked>
<label for="model-objdet" class="segmented-control-button">객체 탐지/분류</label>
<input type="radio" id="model-fire" name="current-model" value="FIRE">
<label for="model-fire" class="segmented-control-button">화재 감지</label>
<input type="radio" id="model-crowd" name="current-model" value="CROWD">
<label for="model-crowd" class="segmented-control-button">군중 위험 인식</label>
<input type="radio" id="model-deid" name="current-model" value="FACEATTR">
<label for="model-deid" class="segmented-control-button">얼굴/ 인상착의 인식</label>
<input type="radio" id="model-abnorm" name="current-model" value="ABNORM">
<label for="model-abnorm" class="segmented-control-button">이상행동 감지</label>
<div class="mission-center">
<div class="filter-bar" id="filter-bar">
<label class="filter-check"><input type="checkbox" checked> all</label>
<span class="v-line">|</span>
<label class="filter-check"><input type="checkbox" checked> waiting...</label>
</div>
<input type="radio" id="model-lpr" name="current-model" value="LPR">
<label for="model-lpr" class="segmented-control-button">차량 번호판 인식</label>
<div class="video-stage">
<div class="video-placeholder-text"></div>
<canvas id="frame"></canvas>
<div id="bbox-container"></div>
</div>
<input type="radio" id="model-viptrack" name="current-model" value="VIPTRACK">
<label for="model-viptrack" class="segmented-control-button">관심인물추적</label>
<div class="mission-footer">
<div class="status-left">
<span id="connection-status" class="conn-active">접속됨</span>
<span class="v-line">|</span>
<span id="connection-msg">영상 수신 중</span>
</div>
<div class="status-right" style="display:flex; align-items:center;">
<span id="resolution-display">-</span>
<span id="fps-display">0 FPS</span>
</div>
</div>
</div>
<aside class="mission-right">
<div class="right-header">
<span class="active">Summary</span>
<span>Log</span>
</div>
<div class="summary-content" id="summary-list">
<div class="summary-row">
<span class="label">Waiting...</span>
<span class="count">-</span>
</div>
</div>
</aside>
</div>
</div>
@ -84,19 +130,39 @@
</thead>
<tbody>
<tr data-role="OBJDET">
<td>1</td><td>객체 탐지/ 분류</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
<td>1</td>
<td>객체 탐지/ 분류</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="FIRE">
<td>2</td><td>화재(불꽃, 연기) 감지</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
<td>2</td>
<td>화재(불꽃, 연기) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="ABNORM">
<td>3</td><td>이상행동(쓰러짐, 폭행) 감지</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
<td>3</td>
<td>이상행동(쓰러짐, 폭행) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="FACEATTR">
<td>4</td><td>얼굴/ 인상착의 인식</td><td class="model-filename">-</td><td class="model-version">-</td><td><button class="btn-delete">삭제</button></td>
<td>4</td>
<td>얼굴/ 인상착의 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="LPR">
<td>5</td><td>차량 번호판/ 차종 인식</td><td class="model-filename">-</td><td class="model-version">-</td><td><button class="btn-delete">삭제</button></td>
<td>5</td>
<td>차량 번호판/ 차종 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td><button class="btn-delete">삭제</button></td>
</tr>
</tbody>
</table>
@ -113,15 +179,14 @@
<table class="model-table" id="poi-table">
<thead>
<tr>
<th style="width: 10%;">번호</th>
<th style="width: 30%;">관심인물명</th>
<th style="width: 30%;">미리 보기</th>
<th style="width: 30%;">파일 등록</th>
</tr>
<tr>
<th style="width: 10%;">번호</th>
<th style="width: 30%;">관심인물명</th>
<th style="width: 30%;">미리 보기</th>
<th style="width: 30%;">파일 등록</th>
</tr>
</thead>
<tbody>
</tbody>
<tbody></tbody>
</table>
<div id="poi-empty-msg" class="empty-message hidden">
등록된 관심 인물이 없습니다
@ -151,4 +216,4 @@
</body>
</html>
<!--//2025.11.25 16:16-->
<!--//2025-12-04 15:13-->

@ -1,18 +1,18 @@
/* style.css */
/* style.css - Merged & Updated */
/* 기본 및 공통 스타일 */
/* ========================================================== */
/* 1. 기본 및 공통 스타일 (Base) */
/* ========================================================== */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
font-family: 'Segoe UI', Arial, sans-serif;
margin: 0; padding: 0;
background-color: #1a1a1a; /* 메인 테마: 다크 모드 */
color: #e0e0e0;
display: flex; flex-direction: column;
height: 100vh; overflow: hidden;
}
/* 버튼 기본 스타일 reset 및 공통 */
/* 버튼 기본 스타일 (공통 Reset) */
button {
cursor: pointer;
border-radius: 4px;
@ -20,200 +20,271 @@ button {
background-color: #3a3a3a;
color: #e0e0e0;
padding: 5px 10px;
font-family: inherit;
}
/* 로그인 페이지 등 배경 스타일 */
/* ========================================================== */
/* 2. 로그인 페이지 스타일 */
/* ========================================================== */
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
/* 배경 이미지 설정 (전체 화면 꽉 채우기) */
background: url('drone_background.png') no-repeat center center fixed;
background-size: cover;
position: relative; /* page-title 절대 위치의 기준점 */
position: relative;
}
/* [수정됨] 로그인 화면 메인 타이틀 스타일 */
.page-title {
position: absolute;
top: 60px; /* 상단 여백 */
left: 60px; /* 좌측 여백 */
color: #ffffff; /* 흰색 텍스트 */
font-size: 3rem; /* 크기 2배 (약 64px ~ 70px) */
top: 60px; left: 60px;
color: #ffffff;
font-size: 3rem;
font-weight: 600;
margin: 0;
z-index: 10;
/* 배경과 구분을 위한 검은 그림자 */
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8);
}
.login-container {
background: rgba(255, 255, 255, 0.95); /* 배경이 비치지 않도록 불투명도 조정 */
background: rgba(255, 255, 255, 0.95);
padding: 2rem 3rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); /* 그림자 조금 더 진하게 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
width: 400px;
box-sizing: border-box;
z-index: 20;
color: #333;
}
.login-container h1 {
font-size: 2.2rem;
color: #333;
margin-bottom: 0.5rem;
font-weight: bold;
text-align: center; /* 로그인 박스 내부 타이틀 중앙 정렬 */
text-align: center;
}
.login-subtext {
font-size: 1rem;
color: #666;
margin: 0 0 2.5rem 0;
text-align: center; /* 서브 텍스트 중앙 정렬 */
font-size: 1rem; color: #666; margin: 0 0 2.5rem 0; text-align: center;
}
.input-group {
margin-bottom: 1rem;
position: relative;
text-align: left;
margin-bottom: 1rem; position: relative; text-align: left;
}
.input-group input {
width: 100%;
padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
background-color: #fff;
color: #333;
font-size: 1rem;
width: 100%; padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd; border-radius: 4px;
box-sizing: border-box; background-color: #fff; color: #333; font-size: 1rem;
}
.input-group .icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
font-size: 1.2rem;
position: absolute; left: 10px; top: 50%;
transform: translateY(-50%); font-size: 1.2rem; color: #666;
}
/* [수정됨] 로그인 버튼 스타일 (중앙 정렬 추가) */
.login-button {
width: auto;
min-width: 150px;
width: auto; min-width: 150px;
padding: 0.8rem 2.5rem;
background-color: #5a67d8;
color: white;
border: none;
font-size: 1rem;
font-weight: bold;
/* 중앙 정렬을 위한 설정 */
display: block;
margin: 1.5rem auto 0 auto;
background-color: #5a67d8; color: white;
border: none; font-size: 1rem; font-weight: bold;
display: block; margin: 1.5rem auto 0 auto;
}
.login-button:hover { background-color: #434190; }
.footer-bar {
position: absolute; bottom: 0; left: 0; width: 100%; height: 50px;
background-color: #ffffff; opacity: 0.7; z-index: 100;
display: flex; justify-content: center; align-items: center; gap: 20px;
color: #333;
}
/* 대시보드 헤더 */
/* ========================================================== */
/* 3. 헤더 및 네비게이션 (Header) */
/* ========================================================== */
header {
background-color: #333;
padding: 0 1rem;
flex-shrink: 0;
height: 50px; flex-shrink: 0;
border-bottom: 1px solid #444;
}
.tabs { display: flex; justify-content: space-between; align-items: center; }
.tabs { display: flex; justify-content: space-between; align-items: center; height: 100%; }
.tab-button {
background: none; border: none; color: #aaa;
padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold;
padding: 0 1.5rem; height: 50px; font-size: 1rem; font-weight: bold;
cursor: pointer;
}
.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; }
.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; background: rgba(255,255,255,0.05); }
.logout-button {
background-color: #d9534f; color: white; border: none;
padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold;
padding: 0.3rem 1rem; border-radius: 4px; font-weight: bold; cursor: pointer;
}
/* 메인 컨텐츠 (다크 테마) */
/* ========================================================== */
/* 4. 메인 컨텐츠 영역 (Main Layout) */
/* ========================================================== */
main {
padding: 1rem;
flex-grow: 1; display: flex; flex-direction: column; min-height: 0; position: relative;
}
.tab-content { display: none; width: 100%; height: 100%; }
.tab-content.active { display: flex; flex-direction: column; }
/* ========================================================== */
/* 5. [신규] Mission View (3단 레이아웃) 스타일 */
/* ========================================================== */
.mission-container {
display: flex; width: 100%; height: 100%;
background-color: #2b2b2b;
}
/* 5-1. 좌측 사이드바 (모델 메뉴) */
.sidebar-nav {
width: 90px;
background-color: #e0e0e0;
display: flex; flex-direction: column;
border-right: 1px solid #999;
}
.nav-item {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 15px 5px; color: #555; cursor: pointer;
border-bottom: 1px solid #ccc; transition: all 0.2s;
}
.nav-item:hover { background-color: #d0d0d0; }
.nav-item.active {
background-color: #4a4a4a; color: #fff;
}
.nav-icon { width: 32px; height: 32px; margin-bottom: 5px; position: relative; }
.nav-item span { font-size: 11px; font-weight: bold; text-align: center; }
.nav-icon svg { width: 100%; height: 100%; }
/* 5-2. 중앙 영역 (필터 + 비디오 + 상태바) */
.mission-center {
flex-grow: 1; display: flex; flex-direction: column;
position: relative; background-color: #555;
/* 추가: 너비 확보 */
min-width: 0;
}
/* 상단 필터 바 */
.filter-bar {
height: 40px; background-color: #444;
display: flex; align-items: center; padding: 0 15px;
color: #fff; border-bottom: 1px solid #666;
font-size: 14px; gap: 10px;
}
.filter-check {
display: flex; align-items: center; gap: 5px; cursor: pointer; user-select: none;
}
.v-line { color: #888; margin: 0 5px; }
/* [수정] 비디오 스테이지 (전체 채우기) */
.video-stage {
flex-grow: 1;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
background-color: #1a1a1a;
color: #e0e0e0;
justify-content: center;
align-items: center;
background-color: #000; /* 비디오 배경은 보통 검정 */
overflow: hidden;
}
.tab-content { display: none; }
.tab-content.active {
display: flex; flex-direction: column; flex-grow: 1; min-height: 0;
.video-placeholder-text {
position: absolute; color: #555; font-size: 1.2rem; z-index: 0; pointer-events: none;
}
/* Video 스타일 */
.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; }
.video-player {
width: 100%; flex-grow: 1; min-height: 0;
background-color: #111; color: #eee;
display: flex; flex-direction: row; gap: 10px;
padding: 10px; box-sizing: border-box; border-radius: 4px; position: relative;
/* [추가/수정] 캔버스와 BBox 컨테이너를 꽉 차게 설정 */
#frame {
display: block;
width: 100%;
height: 100%;
object-fit: contain; /* 비율 유지하며 꽉 차게 */
}
#video-wrap { flex: 2; display: flex; flex-direction: column; gap: 4px; position: relative; }
#frame { flex: 1; min-height: 0; max-width: 100%; background: #000; border: 1px solid #333; z-index: 1; }
#det-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; z-index: 1; }
#det-list { flex: 1; font-size: 11px; background: #000; border: 1px solid #333; padding: 6px; overflow-y: auto; white-space: pre; }
#bbox-container { position: absolute; pointer-events: none; z-index: 5; top: 0; left: 0; }
.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
/* Segmented Control (모델 선택) */
.segmented-control-container {
position: absolute; top: 40px; left: 20px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 4px;
background-color: rgba(50, 50, 50, 0.7); padding: 4px; border-radius: 6px;
#bbox-container {
position: absolute;
pointer-events: none;
z-index: 5;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
}
.segmented-control-container input[type="radio"] { display: none; }
.segmented-control-button {
display: inline-block; padding: 6px 10px; font-size: 0.85rem;
color: #eee; background-color: #555; border: 1px solid #777; border-radius: 4px;
cursor: pointer; user-select: none;
.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
/* 하단 상태 바 */
.mission-footer {
height: 60px;
background-color: #444;
display: flex; justify-content: space-between; align-items: center;
padding: 0 15px; font-size: 13px; color: #eee;
border-top: 1px solid #666;
flex-shrink: 0; /* 푸터 크기 고정 */
}
.segmented-control-container input[type="radio"]:checked + .segmented-control-button {
background-color: #5a67d8; color: white; border-color: #434190; font-weight: bold;
.status-left { display: flex; gap: 10px; }
.conn-active { font-weight: bold; color: #fff; }
#fps-display {
font-size: 3em;
color: #FFCC00;
font-weight: bold;
margin-left: 15px;
line-height: 1;
}
/* Video Overlays */
.video-overlay {
position: absolute; z-index: 10;
background-color: rgba(0, 0, 0, 0.5); color: #ffffff;
padding: 4px 8px; border-radius: 4px; font-weight: bold; pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
#resolution-display {
font-size: 1.5em;
color: #FFF;
font-weight: bold;
margin-left: 15px;
line-height: 1;
}
.video-overlay.top-right { top: 100px; right: 10px; font-size: 60px; }
.video-overlay.bottom-right { bottom: 70px; right: 10px; font-size: 28px; color: #FFEE00; }
/* 5-3. 우측 사이드바 (정보 패널) */
.mission-right {
width: 200px; background-color: #444;
border-left: 1px solid #666;
display: flex; flex-direction: column;
}
.right-header {
height: 40px; display: flex; border-bottom: 1px solid #666;
}
.right-header span {
flex: 1; display: flex; align-items: center; justify-content: center;
font-size: 13px; cursor: pointer; color: #aaa; background: #3a3a3a;
}
.right-header span.active {
color: #fff; background: #555; font-weight: bold;
}
.summary-content { padding: 10px; overflow-y: auto; flex-grow: 1; }
.summary-row {
display: flex; justify-content: space-between;
padding: 8px 5px; border-bottom: 1px solid #555; font-size: 14px;
}
.summary-row .label { color: #fff; }
.summary-row .count { color: #fff; font-weight: bold; }
/* ========================================================== */
/* [설정 탭 - AI 모델 & 관심 인물 관리 스타일] */
/* 7. 설정 탭 - AI 모델 & 관심 인물 관리 (Settings) */
/* ========================================================== */
.toolbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 1rem;
display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; padding: 1rem;
}
.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; }
.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; color: #fff; }
.right-actions { margin-left: auto; display: flex; gap: 10px; }
/* 공통 테이블 스타일 */
.model-table {
width: 100%; border-collapse: collapse; background: #2a2a2a;
}
.model-table th, .model-table td {
border: 1px solid #444; padding: 0.8rem 1rem; vertical-align: middle;
}
.model-table th { background-color: #333; text-align: center; }
.model-table td:first-child { text-align: center; } /* 번호 */
.model-table td { text-align: center; } /* 기본 중앙 정렬 */
.model-table th { background-color: #333; text-align: center; color: #ddd;}
.model-table td { text-align: center; color: #ccc; }
/* 파일명 표시 input */
#file-name-display {
font-size: 0.9rem; color: #ccc; background-color: #2a2a2a;
border: 1px solid #444; padding: 5px 10px; border-radius: 4px;
@ -222,130 +293,45 @@ main {
.hidden-file-input { display: none; }
.hidden { display: none !important; }
/* 버튼 스타일 */
/* 버튼 스타일 (설정 페이지) */
.btn-browse {
cursor: pointer; border-radius: 4px; border: 1px solid #666;
padding: 5px 10px; font-size: 14px; background-color: #4f4f4f; color: #e0e0e0;
}
.btn-browse:hover { background-color: #5a5a5a; }
.btn-action { background-color: #4CAF50; color: white; border: none; }
.btn-delete { background-color: #f44336; color: white; border: none; }
.refresh-button { margin-left: auto; }
/* 흰색 배경 버튼 (UI 디자인 반영) */
.btn-white {
background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold;
}
.btn-white:hover { background-color: #f0f0f0; }
/* 구분선 */
.divider {
height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none;
}
.btn-action { background-color: #4CAF50; color: white; border: none; padding:5px 10px; border-radius:4px; cursor:pointer;}
.btn-delete { background-color: #f44336; color: white; border: none; padding:5px 10px; border-radius:4px; cursor:pointer;}
.refresh-button { margin-left: auto; padding:5px 10px; cursor:pointer; }
.btn-white { background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold; padding:5px 15px; border-radius:4px; cursor:pointer;}
.divider { height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none; }
.empty-message { text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem; }
/* POI 미리보기 */
.preview-container { display: flex; flex-direction: column; gap: 8px; align-items: center; justify-content: center; }
.preview-item { display: flex; align-items: center; gap: 10px; background: rgba(255, 255, 255, 0.05); padding: 4px; border-radius: 4px; }
.poi-preview { width: 50px; height: 50px; object-fit: cover; border-radius: 4px; border: 1px solid #555; display: block; cursor: pointer; transition: 0.3s; }
.poi-preview:hover { opacity: 0.7; }
.btn-delete-small { background-color: #fff; border: 1px solid #ccc; color: #333; padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px; border-radius: 2px; }
/* ========================================================== */
/* [신규] 관심 인물(POI) 관리 전용 스타일 */
/* 8. 모달 (Popups) */
/* ========================================================== */
/* 빈 목록 메시지 */
.empty-message {
text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem;
}
/* 미리보기 컨테이너 (세로 정렬) */
.preview-container {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
/* 개별 이미지 아이템 (이미지 + 삭제버튼) */
.preview-item {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 4px;
}
/* 미리보기 이미지 크기 */
.poi-preview {
width: 50px; height: 50px; object-fit: cover;
border-radius: 4px; border: 1px solid #555; display: block; margin: 0 auto;
cursor: pointer; /* 클릭 가능 표시 */
transition: 0.3s;
}
.poi-preview:hover { opacity: 0.7; }
/* 삭제 버튼 공통 */
.btn-delete-small {
background-color: #fff; border: 1px solid #ccc; color: #333;
padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px;
border-radius: 2px;
}
.btn-delete-small:hover { background-color: #eee; }
/* 모달 관련 스타일 (기존 유지) */
.modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex; justify-content: center; align-items: center; z-index: 2000;
}
.modal-content {
background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333;
}
.modal-content h3 { margin-top: 0; margin-bottom: 2rem; text-align: left; font-size: 1.2rem; font-weight: bold; }
.modal-body { margin-bottom: 2rem; }
.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; text-align: center; box-sizing: border-box; }
.modal-actions { display: flex; justify-content: center; gap: 20px; }
.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; }
.btn-modal-confirm { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-cancel { background-color: #fff; color: #333; border: 1px solid #999; }
.modal-content { background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333; border-radius:8px;}
.modal-content h3 { margin-top: 0; margin-bottom: 2rem; }
.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; }
.modal-actions { display: flex; justify-content: center; gap: 20px; margin-top:20px; }
.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; border:1px solid #999; background:#fff; }
.btn-modal-confirm:hover, .btn-modal-cancel:hover { background-color: #eee; }
/* ========================================================== */
/* [신규] 이미지 뷰어 모달 스타일 */
/* ========================================================== */
/* 뷰어 모달 이미지 */
.modal-image {
margin: auto;
display: block;
max-width: 90%;
max-height: 90%;
border-radius: 5px;
animation-name: zoom;
animation-duration: 0.4s;
}
/* 확대 애니메이션 */
@keyframes zoom {
from {transform:scale(0)}
to {transform:scale(1)}
}
/* 닫기 버튼 (X) */
.close-viewer {
position: absolute;
top: 20px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.close-viewer:hover,
.close-viewer:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}
/* 이미지 뷰어 모달 */
.modal-image { margin: auto; display: block; max-width: 90%; max-height: 90%; border-radius: 5px; animation-name: zoom; animation-duration: 0.4s; }
@keyframes zoom { from {transform:scale(0)} to {transform:scale(1)} }
.close-viewer { position: absolute; top: 20px; right: 35px; color: #f1f1f1; font-size: 40px; font-weight: bold; transition: 0.3s; cursor: pointer; z-index: 2001; }
/*2025.11.25 16:16*/
/*//2025-12-04 15:13*/
Loading…
Cancel
Save