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">×</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*/
|
||||
Loading…
Reference in new issue