feat(aerial): CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
CCTV 오일 유출 감지: - GPU 추론 서버 FastAPI 서비스 (oil_inference_server.py) - Express 프록시 엔드포인트 (POST /api/aerial/oil-detect) - 프론트엔드 API 연동 (oilDetection.ts, useOilDetection.ts) - 4종 유류 클래스별 색상 오버레이 (OilDetectionOverlay.tsx) - 캡처 기능 (비디오+오버레이 합성 PNG 다운로드) - Rate limit HLS 스트리밍 skip + 한도 500 상향 HNS 대기확산: - 초기 핀 포인트 제거 (지도 클릭으로 선택) - 좌표 미선택 시 안내 메시지 표시 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
e65e722059
커밋
626fea4c75
@ -7,6 +7,8 @@ import {
|
|||||||
createSatRequest,
|
createSatRequest,
|
||||||
updateSatRequestStatus,
|
updateSatRequestStatus,
|
||||||
isValidSatStatus,
|
isValidSatStatus,
|
||||||
|
requestOilInference,
|
||||||
|
checkInferenceHealth,
|
||||||
} from './aerialService.js';
|
} from './aerialService.js';
|
||||||
import { isValidNumber } from '../middleware/security.js';
|
import { isValidNumber } from '../middleware/security.js';
|
||||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
import { requireAuth, requirePermission } from '../auth/authMiddleware.js';
|
||||||
@ -221,4 +223,44 @@ router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'C
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// OIL INFERENCE 라우트
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// POST /api/aerial/oil-detect — 오일 유출 감지 (GPU 추론 서버 프록시)
|
||||||
|
// base64 이미지 전송을 위해 3MB JSON 파서 적용
|
||||||
|
router.post('/oil-detect', express.json({ limit: '3mb' }), requireAuth, requirePermission('aerial', 'READ'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { image } = req.body;
|
||||||
|
if (!image || typeof image !== 'string') {
|
||||||
|
res.status(400).json({ error: 'image (base64) 필드가 필요합니다' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// base64 크기 제한 (약 2MB 이미지)
|
||||||
|
if (image.length > 3_000_000) {
|
||||||
|
res.status(400).json({ error: '이미지 크기가 너무 큽니다 (최대 2MB)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestOilInference(image);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
if (message.includes('abort') || message.includes('timeout')) {
|
||||||
|
console.error('[aerial] 추론 서버 타임아웃:', message);
|
||||||
|
res.status(504).json({ error: '추론 서버 응답 시간 초과' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[aerial] 오일 감지 오류:', err);
|
||||||
|
res.status(503).json({ error: '추론 서버 연결 불가' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/aerial/oil-detect/health — 추론 서버 상태 확인
|
||||||
|
router.get('/oil-detect/health', requireAuth, async (_req, res) => {
|
||||||
|
const health = await checkInferenceHealth();
|
||||||
|
res.json(health);
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
|
|||||||
[sttsCd, sn]
|
[sttsCd, sn]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// OIL INFERENCE (GPU 서버 프록시)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:8090';
|
||||||
|
const INFERENCE_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export interface OilInferenceRegion {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
pixelCount: number;
|
||||||
|
percentage: number;
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OilInferenceResult {
|
||||||
|
mask: string; // base64 uint8 array (values 0-4)
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
regions: OilInferenceRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPU 추론 서버에 이미지를 전송하고 세그멘테이션 결과를 반환한다. */
|
||||||
|
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: imageBase64 }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Inference server responded ${response.status}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as OilInferenceResult;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GPU 추론 서버 헬스체크 */
|
||||||
|
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${OIL_INFERENCE_URL}/health`, {
|
||||||
|
signal: AbortSignal.timeout(3000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`status ${response.status}`);
|
||||||
|
return await response.json() as { status: string; device?: string };
|
||||||
|
} catch {
|
||||||
|
return { status: 'unavailable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
185
backend/src/inference/oil_inference_server.py
Normal file
185
backend/src/inference/oil_inference_server.py
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
오일 유출 감지 추론 서버 (GPU)
|
||||||
|
시립대 starsafire ResNet101+DANet 모델 기반
|
||||||
|
|
||||||
|
실행: uvicorn oil_inference_server:app --host 0.0.0.0 --port 8090
|
||||||
|
모델 파일 필요: ./V7_SPECIAL.py, ./epoch_165.pth (같은 디렉토리)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# ── MMSegmentation (지연 임포트 — 서버 시작 시 로드) ─────────────────────────
|
||||||
|
model = None
|
||||||
|
DEVICE = os.getenv("INFERENCE_DEVICE", "cuda:0")
|
||||||
|
|
||||||
|
CLASSES = ("background", "black", "brown", "rainbow", "silver")
|
||||||
|
PALETTE = [
|
||||||
|
[0, 0, 0], # 0: background
|
||||||
|
[0, 0, 204], # 1: black oil (에멀전)
|
||||||
|
[180, 180, 180], # 2: brown oil (원유)
|
||||||
|
[255, 255, 0], # 3: rainbow oil (박막)
|
||||||
|
[178, 102, 255], # 4: silver oil (극박막)
|
||||||
|
]
|
||||||
|
THICKNESS_MM = {
|
||||||
|
1: 1.0, # black
|
||||||
|
2: 0.1, # brown
|
||||||
|
3: 0.0003, # rainbow
|
||||||
|
4: 0.0001, # silver
|
||||||
|
}
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("oil-inference")
|
||||||
|
|
||||||
|
# ── FastAPI App ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app = FastAPI(title="Oil Spill Inference Server", version="1.0.0")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceRequest(BaseModel):
|
||||||
|
image: str # base64 encoded JPEG/PNG
|
||||||
|
|
||||||
|
|
||||||
|
class OilRegionResult(BaseModel):
|
||||||
|
classId: int
|
||||||
|
className: str
|
||||||
|
pixelCount: int
|
||||||
|
percentage: float
|
||||||
|
thicknessMm: float
|
||||||
|
|
||||||
|
|
||||||
|
class InferenceResponse(BaseModel):
|
||||||
|
mask: str # base64 encoded uint8 array (values 0-4)
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
regions: list[OilRegionResult]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model Loading ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
"""모델을 로드한다. 서버 시작 시 1회 호출."""
|
||||||
|
global model
|
||||||
|
try:
|
||||||
|
from mmseg.apis import init_segmentor
|
||||||
|
|
||||||
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
config_path = os.path.join(script_dir, "V7_SPECIAL.py")
|
||||||
|
checkpoint_path = os.path.join(script_dir, "epoch_165.pth")
|
||||||
|
|
||||||
|
if not os.path.exists(config_path):
|
||||||
|
logger.error(f"Config not found: {config_path}")
|
||||||
|
return False
|
||||||
|
if not os.path.exists(checkpoint_path):
|
||||||
|
logger.error(f"Checkpoint not found: {checkpoint_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"Loading model on {DEVICE}...")
|
||||||
|
model = init_segmentor(config_path, checkpoint_path, device=DEVICE)
|
||||||
|
model.PALETTE = PALETTE
|
||||||
|
logger.info("Model loaded successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Model loading failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
success = load_model()
|
||||||
|
if not success:
|
||||||
|
logger.warning("Model not loaded — inference will be unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"status": "ok" if model is not None else "model_not_loaded",
|
||||||
|
"device": DEVICE,
|
||||||
|
"classes": list(CLASSES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/inference", response_model=InferenceResponse)
|
||||||
|
async def inference(req: InferenceRequest):
|
||||||
|
if model is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Model not loaded")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Base64 → numpy array
|
||||||
|
img_bytes = base64.b64decode(req.image)
|
||||||
|
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
|
||||||
|
img_np = np.array(img_pil)
|
||||||
|
|
||||||
|
# 2. 임시 파일로 저장 (mmseg inference_segmentor는 파일 경로 필요)
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
cv2.imwrite(tmp_path, cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR))
|
||||||
|
|
||||||
|
# 3. 추론
|
||||||
|
from mmseg.apis import inference_segmentor
|
||||||
|
result = inference_segmentor(model, tmp_path)
|
||||||
|
seg_map = result[0] # (H, W) uint8, values 0-4
|
||||||
|
|
||||||
|
# 임시 파일 삭제
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
|
||||||
|
h, w = seg_map.shape
|
||||||
|
total_pixels = h * w
|
||||||
|
|
||||||
|
# 4. 클래스별 통계
|
||||||
|
counter = Counter(seg_map.flatten().tolist())
|
||||||
|
regions = []
|
||||||
|
for class_id in range(1, 5): # 1-4 (skip background)
|
||||||
|
count = counter.get(class_id, 0)
|
||||||
|
if count > 0:
|
||||||
|
regions.append(OilRegionResult(
|
||||||
|
classId=class_id,
|
||||||
|
className=CLASSES[class_id],
|
||||||
|
pixelCount=count,
|
||||||
|
percentage=round(count / total_pixels * 100, 2),
|
||||||
|
thicknessMm=THICKNESS_MM[class_id],
|
||||||
|
))
|
||||||
|
|
||||||
|
# 5. 마스크를 base64로 인코딩
|
||||||
|
mask_bytes = seg_map.astype(np.uint8).tobytes()
|
||||||
|
mask_b64 = base64.b64encode(mask_bytes).decode("ascii")
|
||||||
|
|
||||||
|
return InferenceResponse(
|
||||||
|
mask=mask_b64,
|
||||||
|
width=w,
|
||||||
|
height=h,
|
||||||
|
regions=regions,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Inference error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8090)
|
||||||
8
backend/src/inference/requirements.txt
Normal file
8
backend/src/inference/requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn==0.24.0
|
||||||
|
torch>=1.13.0
|
||||||
|
mmcv-full>=1.7.0
|
||||||
|
mmsegmentation>=0.30.0
|
||||||
|
opencv-python-headless>=4.8.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
Pillow>=10.0.0
|
||||||
@ -97,9 +97,13 @@ app.use(cors({
|
|||||||
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
|
||||||
const generalLimiter = rateLimit({
|
const generalLimiter = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15분
|
windowMs: 15 * 60 * 1000, // 15분
|
||||||
max: 200, // IP당 최대 200요청
|
max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려)
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
skip: (req) => {
|
||||||
|
// HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외
|
||||||
|
return req.path.startsWith('/api/aerial/cctv/stream-proxy');
|
||||||
|
},
|
||||||
message: {
|
message: {
|
||||||
error: '요청 횟수 초과',
|
error: '요청 횟수 초과',
|
||||||
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
import { detectStreamType } from '../utils/streamUtils';
|
import { detectStreamType } from '../utils/streamUtils';
|
||||||
|
import { useOilDetection } from '../hooks/useOilDetection';
|
||||||
|
import OilDetectionOverlay from './OilDetectionOverlay';
|
||||||
|
|
||||||
interface CCTVPlayerProps {
|
interface CCTVPlayerProps {
|
||||||
cameraNm: string;
|
cameraNm: string;
|
||||||
@ -9,6 +11,11 @@ interface CCTVPlayerProps {
|
|||||||
coordDc?: string | null;
|
coordDc?: string | null;
|
||||||
sourceNm?: string | null;
|
sourceNm?: string | null;
|
||||||
cellIndex?: number;
|
cellIndex?: number;
|
||||||
|
oilDetectionEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCTVPlayerHandle {
|
||||||
|
capture: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
|
||||||
@ -21,15 +28,17 @@ function toProxyUrl(url: string): string {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CCTVPlayer({
|
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
|
||||||
cameraNm,
|
cameraNm,
|
||||||
streamUrl,
|
streamUrl,
|
||||||
sttsCd,
|
sttsCd,
|
||||||
coordDc,
|
coordDc,
|
||||||
sourceNm,
|
sourceNm,
|
||||||
cellIndex = 0,
|
cellIndex = 0,
|
||||||
}: CCTVPlayerProps) {
|
oilDetectionEnabled = false,
|
||||||
|
}, ref) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hlsRef = useRef<Hls | null>(null);
|
const hlsRef = useRef<Hls | null>(null);
|
||||||
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
|
||||||
const [retryKey, setRetryKey] = useState(0);
|
const [retryKey, setRetryKey] = useState(0);
|
||||||
@ -56,6 +65,73 @@ export function CCTVPlayer({
|
|||||||
? 'playing'
|
? 'playing'
|
||||||
: hlsPlayerState;
|
: hlsPlayerState;
|
||||||
|
|
||||||
|
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
|
||||||
|
videoRef,
|
||||||
|
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
capture: () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const w = container.clientWidth;
|
||||||
|
const h = container.clientHeight;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w * 2;
|
||||||
|
canvas.height = h * 2;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.scale(2, 2);
|
||||||
|
|
||||||
|
// 1) video frame
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (video && video.readyState >= 2) {
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) oil detection overlay
|
||||||
|
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
|
||||||
|
if (overlayCanvas) {
|
||||||
|
ctx.drawImage(overlayCanvas, 0, 0, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) OSD: camera name + timestamp
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
|
||||||
|
ctx.font = 'bold 12px sans-serif';
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillText(cameraNm, 18, 23);
|
||||||
|
|
||||||
|
const ts = new Date().toLocaleString('ko-KR');
|
||||||
|
ctx.font = '10px monospace';
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
||||||
|
const tsW = ctx.measureText(ts).width + 16;
|
||||||
|
ctx.fillRect(8, h - 26, tsW, 20);
|
||||||
|
ctx.fillStyle = '#a0aec0';
|
||||||
|
ctx.fillText(ts, 16, h - 12);
|
||||||
|
|
||||||
|
// 4) oil detection info
|
||||||
|
if (oilResult && oilResult.regions.length > 0) {
|
||||||
|
const areaText = oilResult.totalAreaM2 >= 1000
|
||||||
|
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
|
||||||
|
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
|
||||||
|
ctx.font = 'bold 11px sans-serif';
|
||||||
|
const atW = ctx.measureText(areaText).width + 16;
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.25)';
|
||||||
|
ctx.fillRect(8, h - 48, atW, 18);
|
||||||
|
ctx.fillStyle = '#f87171';
|
||||||
|
ctx.fillText(areaText, 16, h - 34);
|
||||||
|
}
|
||||||
|
|
||||||
|
// download
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
|
||||||
|
link.href = canvas.toDataURL('image/png');
|
||||||
|
link.click();
|
||||||
|
},
|
||||||
|
}), [cameraNm, oilResult]);
|
||||||
|
|
||||||
const destroyHls = useCallback(() => {
|
const destroyHls = useCallback(() => {
|
||||||
if (hlsRef.current) {
|
if (hlsRef.current) {
|
||||||
hlsRef.current.destroy();
|
hlsRef.current.destroy();
|
||||||
@ -185,7 +261,7 @@ export function CCTVPlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div ref={containerRef} className="absolute inset-0">
|
||||||
{/* 로딩 오버레이 */}
|
{/* 로딩 오버레이 */}
|
||||||
{playerState === 'loading' && (
|
{playerState === 'loading' && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
|
||||||
@ -207,13 +283,18 @@ export function CCTVPlayer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 오일 감지 오버레이 */}
|
||||||
|
{oilDetectionEnabled && (
|
||||||
|
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* MJPEG */}
|
{/* MJPEG */}
|
||||||
{streamType === 'mjpeg' && proxiedUrl && (
|
{streamType === 'mjpeg' && proxiedUrl && (
|
||||||
<img
|
<img
|
||||||
src={proxiedUrl}
|
src={proxiedUrl}
|
||||||
alt={cameraNm}
|
alt={cameraNm}
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
onError={() => setPlayerState('error')}
|
onError={() => setHlsPlayerState('error')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -224,7 +305,7 @@ export function CCTVPlayer({
|
|||||||
title={cameraNm}
|
title={cameraNm}
|
||||||
className="absolute inset-0 w-full h-full border-none"
|
className="absolute inset-0 w-full h-full border-none"
|
||||||
allow="autoplay; encrypted-media"
|
allow="autoplay; encrypted-media"
|
||||||
onError={() => setPlayerState('error')}
|
onError={() => setHlsPlayerState('error')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -245,6 +326,8 @@ export function CCTVPlayer({
|
|||||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
|
||||||
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
CCTVPlayer.displayName = 'CCTVPlayer';
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { fetchCctvCameras } from '../services/aerialApi'
|
import { fetchCctvCameras } from '../services/aerialApi'
|
||||||
import type { CctvCameraItem } from '../services/aerialApi'
|
import type { CctvCameraItem } from '../services/aerialApi'
|
||||||
import { CCTVPlayer } from './CCTVPlayer'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
|
import type { CCTVPlayerHandle } from './CCTVPlayer'
|
||||||
|
|
||||||
/** KHOA HLS 스트림 베이스 URL */
|
/** KHOA HLS 스트림 베이스 URL */
|
||||||
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
|
const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa';
|
||||||
@ -54,6 +55,8 @@ export function CctvView() {
|
|||||||
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
|
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
|
||||||
const [gridMode, setGridMode] = useState(1)
|
const [gridMode, setGridMode] = useState(1)
|
||||||
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
|
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
|
||||||
|
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
|
||||||
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@ -226,7 +229,23 @@ export function CctvView() {
|
|||||||
>{g.icon}</button>
|
>{g.icon}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors">📷 캡처</button>
|
<button
|
||||||
|
onClick={() => setOilDetectionEnabled(v => !v)}
|
||||||
|
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||||||
|
style={oilDetectionEnabled
|
||||||
|
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--red)' }
|
||||||
|
: { background: 'var(--bg3)', borderColor: 'var(--bd)', color: 'var(--t2)' }
|
||||||
|
}
|
||||||
|
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
|
||||||
|
>
|
||||||
|
{oilDetectionEnabled ? '🛢 감지 ON' : '🛢 오일 감지'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
playerRefs.current.forEach(r => r?.capture())
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 bg-bg-3 border border-border rounded-[5px] text-text-2 text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-hover transition-colors"
|
||||||
|
>📷 캡처</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -242,12 +261,14 @@ export function CctvView() {
|
|||||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
{cam ? (
|
{cam ? (
|
||||||
<CCTVPlayer
|
<CCTVPlayer
|
||||||
|
ref={el => { playerRefs.current[i] = el }}
|
||||||
cameraNm={cam.cameraNm}
|
cameraNm={cam.cameraNm}
|
||||||
streamUrl={cam.streamUrl}
|
streamUrl={cam.streamUrl}
|
||||||
sttsCd={cam.sttsCd}
|
sttsCd={cam.sttsCd}
|
||||||
coordDc={cam.coordDc}
|
coordDc={cam.coordDc}
|
||||||
sourceNm={cam.sourceNm}
|
sourceNm={cam.sourceNm}
|
||||||
cellIndex={i}
|
cellIndex={i}
|
||||||
|
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||||
|
|||||||
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
161
frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
import { useRef, useEffect, memo } from 'react';
|
||||||
|
import type { OilDetectionResult } from '../utils/oilDetection';
|
||||||
|
import { OIL_CLASSES, OIL_CLASS_NAMES } from '../utils/oilDetection';
|
||||||
|
|
||||||
|
export interface OilDetectionOverlayProps {
|
||||||
|
result: OilDetectionResult | null;
|
||||||
|
isAnalyzing?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 클래스 ID → RGBA 색상 (오버레이용) */
|
||||||
|
const CLASS_COLORS: Record<number, [number, number, number, number]> = {
|
||||||
|
1: [0, 0, 204, 90], // black oil → 파란색
|
||||||
|
2: [180, 180, 180, 90], // brown oil → 회색
|
||||||
|
3: [255, 255, 0, 90], // rainbow oil → 노란색
|
||||||
|
4: [178, 102, 255, 90], // silver oil → 보라색
|
||||||
|
};
|
||||||
|
|
||||||
|
const OilDetectionOverlay = memo(({ result, isAnalyzing = false, error = null }: OilDetectionOverlayProps) => {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const displayW = canvas.clientWidth;
|
||||||
|
const displayH = canvas.clientHeight;
|
||||||
|
|
||||||
|
canvas.width = displayW * dpr;
|
||||||
|
canvas.height = displayH * dpr;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, displayW, displayH);
|
||||||
|
|
||||||
|
if (!result || result.regions.length === 0) return;
|
||||||
|
|
||||||
|
const { mask, maskWidth, maskHeight } = result;
|
||||||
|
|
||||||
|
// 클래스별 색상으로 마스크 렌더링
|
||||||
|
const offscreen = new OffscreenCanvas(maskWidth, maskHeight);
|
||||||
|
const offCtx = offscreen.getContext('2d');
|
||||||
|
if (offCtx) {
|
||||||
|
const imageData = new ImageData(maskWidth, maskHeight);
|
||||||
|
for (let i = 0; i < mask.length; i++) {
|
||||||
|
const classId = mask[i];
|
||||||
|
if (classId === 0) continue; // background skip
|
||||||
|
|
||||||
|
const color = CLASS_COLORS[classId];
|
||||||
|
if (!color) continue;
|
||||||
|
|
||||||
|
const pixelIdx = i * 4;
|
||||||
|
imageData.data[pixelIdx] = color[0];
|
||||||
|
imageData.data[pixelIdx + 1] = color[1];
|
||||||
|
imageData.data[pixelIdx + 2] = color[2];
|
||||||
|
imageData.data[pixelIdx + 3] = color[3];
|
||||||
|
}
|
||||||
|
offCtx.putImageData(imageData, 0, 0);
|
||||||
|
ctx.drawImage(offscreen, 0, 0, displayW, displayH);
|
||||||
|
}
|
||||||
|
}, [result]);
|
||||||
|
|
||||||
|
const formatArea = (m2: number): string => {
|
||||||
|
if (m2 >= 1000) {
|
||||||
|
return `${(m2 / 1_000_000).toFixed(1)} km²`;
|
||||||
|
}
|
||||||
|
return `~${Math.round(m2)} m²`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRegions = result !== null && result.regions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className='absolute inset-0 w-full h-full pointer-events-none z-[15]'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */}
|
||||||
|
<div className='absolute bottom-8 left-2 z-20 flex flex-col items-start gap-1'>
|
||||||
|
{/* 에러 표시 */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.2)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.5)',
|
||||||
|
color: '#f87171',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
추론 서버 연결 불가
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 클래스별 감지 결과 */}
|
||||||
|
{hasRegions && result !== null && (
|
||||||
|
<>
|
||||||
|
{result.regions.map((region) => {
|
||||||
|
const oilClass = OIL_CLASSES.find((c) => c.classId === region.classId);
|
||||||
|
const color = oilClass ? `rgb(${oilClass.color.join(',')})` : '#f87171';
|
||||||
|
const label = OIL_CLASS_NAMES[region.classId] || region.className;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={region.classId}
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: `${color}33`,
|
||||||
|
border: `1px solid ${color}80`,
|
||||||
|
color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* 합계 */}
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(239,68,68,0.2)',
|
||||||
|
border: '1px solid rgba(239,68,68,0.5)',
|
||||||
|
color: '#f87171',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%)
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 감지 없음 */}
|
||||||
|
{!hasRegions && !isAnalyzing && !error && (
|
||||||
|
<div
|
||||||
|
className='px-2 py-0.5 rounded text-[10px] font-semibold font-korean'
|
||||||
|
style={{
|
||||||
|
background: 'rgba(34,197,94,0.15)',
|
||||||
|
border: '1px solid rgba(34,197,94,0.35)',
|
||||||
|
color: '#4ade80',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
감지 없음
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 분석 중 */}
|
||||||
|
{isAnalyzing && (
|
||||||
|
<span className='text-[9px] font-korean text-text-3 animate-pulse px-1'>
|
||||||
|
분석중...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
OilDetectionOverlay.displayName = 'OilDetectionOverlay';
|
||||||
|
|
||||||
|
export default OilDetectionOverlay;
|
||||||
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
84
frontend/src/tabs/aerial/hooks/useOilDetection.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { OilDetectionResult, OilDetectionConfig } from '../utils/oilDetection';
|
||||||
|
import { detectOilSpillAPI, DEFAULT_OIL_DETECTION_CONFIG } from '../utils/oilDetection';
|
||||||
|
|
||||||
|
interface UseOilDetectionOptions {
|
||||||
|
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||||
|
enabled: boolean;
|
||||||
|
config?: Partial<OilDetectionConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseOilDetectionReturn {
|
||||||
|
result: OilDetectionResult | null;
|
||||||
|
isAnalyzing: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOilDetection(options: UseOilDetectionOptions): UseOilDetectionReturn {
|
||||||
|
const { videoRef, enabled, config } = options;
|
||||||
|
|
||||||
|
const [result, setResult] = useState<OilDetectionResult | null>(null);
|
||||||
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const configRef = useRef<OilDetectionConfig>({
|
||||||
|
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
const isBusyRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = {
|
||||||
|
...DEFAULT_OIL_DETECTION_CONFIG,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const analyze = useCallback(async () => {
|
||||||
|
if (isBusyRef.current) return; // 이전 호출이 진행 중이면 스킵
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video || video.readyState < 2) return;
|
||||||
|
|
||||||
|
isBusyRef.current = true;
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detection = await detectOilSpillAPI(video, configRef.current);
|
||||||
|
setResult(detection);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
// API 실패 시 이전 결과 유지, 에러 메시지만 갱신
|
||||||
|
const message = err instanceof Error ? err.message : '추론 서버 연결 불가';
|
||||||
|
setError(message);
|
||||||
|
console.warn('[OilDetection] API 호출 실패:', message);
|
||||||
|
} finally {
|
||||||
|
isBusyRef.current = false;
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
}
|
||||||
|
}, [videoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
setResult(null);
|
||||||
|
setIsAnalyzing(false);
|
||||||
|
setError(null);
|
||||||
|
isBusyRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsAnalyzing(true);
|
||||||
|
|
||||||
|
// 첫 분석: 2초 후 (영상 로딩 대기)
|
||||||
|
const firstTimeout = setTimeout(analyze, 2000);
|
||||||
|
// 반복 분석
|
||||||
|
const intervalId = setInterval(analyze, configRef.current.captureIntervalMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(firstTimeout);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, [enabled, analyze]);
|
||||||
|
|
||||||
|
return { result, isAnalyzing, error };
|
||||||
|
}
|
||||||
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
165
frontend/src/tabs/aerial/utils/oilDetection.ts
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
/**
|
||||||
|
* 오일 유출 감지 — GPU 추론 서버 API 연동
|
||||||
|
*
|
||||||
|
* 시립대(starsafire) ResNet101+DANet 모델 기반
|
||||||
|
* 프레임 캡처 → base64 JPEG → POST /api/aerial/oil-detect → 세그멘테이션 결과
|
||||||
|
*
|
||||||
|
* 5개 클래스: background(0), black(1), brown(2), rainbow(3), silver(4)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from '@common/services/api';
|
||||||
|
|
||||||
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface OilDetectionConfig {
|
||||||
|
captureIntervalMs: number; // API 호출 주기 (ms), default 5000
|
||||||
|
coverageAreaM2: number; // 카메라 커버리지 면적 (m²), default 10000
|
||||||
|
captureWidth: number; // 캡처 해상도 (너비), default 512
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 유류 클래스 정의 */
|
||||||
|
export interface OilClass {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
color: [number, number, number]; // RGB
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개별 유류 영역 (API 응답에서 변환) */
|
||||||
|
export interface OilRegion {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
pixelCount: number;
|
||||||
|
percentage: number;
|
||||||
|
areaM2: number;
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 감지 결과 (오버레이에서 사용) */
|
||||||
|
export interface OilDetectionResult {
|
||||||
|
regions: OilRegion[];
|
||||||
|
totalPercentage: number;
|
||||||
|
totalAreaM2: number;
|
||||||
|
mask: Uint8Array; // 클래스 인덱스 (0-4)
|
||||||
|
maskWidth: number;
|
||||||
|
maskHeight: number;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const DEFAULT_OIL_DETECTION_CONFIG: OilDetectionConfig = {
|
||||||
|
captureIntervalMs: 5000,
|
||||||
|
coverageAreaM2: 10000,
|
||||||
|
captureWidth: 512,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 유류 클래스 팔레트 (시립대 starsafire 기준) */
|
||||||
|
export const OIL_CLASSES: OilClass[] = [
|
||||||
|
{ classId: 1, className: 'black', color: [0, 0, 204], thicknessMm: 1.0 },
|
||||||
|
{ classId: 2, className: 'brown', color: [180, 180, 180], thicknessMm: 0.1 },
|
||||||
|
{ classId: 3, className: 'rainbow', color: [255, 255, 0], thicknessMm: 0.0003 },
|
||||||
|
{ classId: 4, className: 'silver', color: [178, 102, 255], thicknessMm: 0.0001 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OIL_CLASS_NAMES: Record<number, string> = {
|
||||||
|
1: '에멀전(Black)',
|
||||||
|
2: '원유(Brown)',
|
||||||
|
3: '무지개막(Rainbow)',
|
||||||
|
4: '은색막(Silver)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Frame Capture ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비디오 프레임을 캡처하여 base64 JPEG 문자열로 반환한다.
|
||||||
|
*/
|
||||||
|
export function captureFrameAsBase64(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
targetWidth: number,
|
||||||
|
): string | null {
|
||||||
|
if (video.readyState < 2 || video.videoWidth === 0) return null;
|
||||||
|
|
||||||
|
const aspect = video.videoHeight / video.videoWidth;
|
||||||
|
const w = targetWidth;
|
||||||
|
const h = Math.round(w * aspect);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
// data:image/jpeg;base64,... → base64 부분만 추출
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
return dataUrl.split(',')[1] || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API Inference ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ApiInferenceRegion {
|
||||||
|
classId: number;
|
||||||
|
className: string;
|
||||||
|
pixelCount: number;
|
||||||
|
percentage: number;
|
||||||
|
thicknessMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiInferenceResponse {
|
||||||
|
mask: string; // base64 uint8 array
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
regions: ApiInferenceRegion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GPU 추론 서버에 프레임을 전송하고 오일 감지 결과를 반환한다.
|
||||||
|
*/
|
||||||
|
export async function detectOilSpillAPI(
|
||||||
|
video: HTMLVideoElement,
|
||||||
|
config: OilDetectionConfig,
|
||||||
|
): Promise<OilDetectionResult | null> {
|
||||||
|
const imageBase64 = captureFrameAsBase64(video, config.captureWidth);
|
||||||
|
if (!imageBase64) return null;
|
||||||
|
|
||||||
|
const response = await api.post<ApiInferenceResponse>('/aerial/oil-detect', {
|
||||||
|
image: imageBase64,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mask: maskB64, width, height, regions: apiRegions } = response.data;
|
||||||
|
const totalPixels = width * height;
|
||||||
|
|
||||||
|
// base64 → Uint8Array
|
||||||
|
const binaryStr = atob(maskB64);
|
||||||
|
const mask = new Uint8Array(binaryStr.length);
|
||||||
|
for (let i = 0; i < binaryStr.length; i++) {
|
||||||
|
mask[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 영역 → OilRegion 변환 (면적 계산 포함)
|
||||||
|
const regions: OilRegion[] = apiRegions.map((r) => ({
|
||||||
|
classId: r.classId,
|
||||||
|
className: r.className,
|
||||||
|
pixelCount: r.pixelCount,
|
||||||
|
percentage: r.percentage,
|
||||||
|
areaM2: (r.pixelCount / totalPixels) * config.coverageAreaM2,
|
||||||
|
thicknessMm: r.thicknessMm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPercentage = regions.reduce((sum, r) => sum + r.percentage, 0);
|
||||||
|
const totalAreaM2 = regions.reduce((sum, r) => sum + r.areaM2, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
regions,
|
||||||
|
totalPercentage,
|
||||||
|
totalAreaM2,
|
||||||
|
mask,
|
||||||
|
maskWidth: width,
|
||||||
|
maskHeight: height,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -36,8 +36,8 @@ export interface HNSInputParams {
|
|||||||
interface HNSLeftPanelProps {
|
interface HNSLeftPanelProps {
|
||||||
activeSubTab: 'analysis' | 'list';
|
activeSubTab: 'analysis' | 'list';
|
||||||
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
onSubTabChange: (tab: 'analysis' | 'list') => void;
|
||||||
incidentCoord: { lon: number; lat: number };
|
incidentCoord: { lon: number; lat: number } | null;
|
||||||
onCoordChange: (coord: { lon: number; lat: number }) => void;
|
onCoordChange: (coord: { lon: number; lat: number } | null) => void;
|
||||||
onMapSelectClick: () => void;
|
onMapSelectClick: () => void;
|
||||||
onRunPrediction: () => void;
|
onRunPrediction: () => void;
|
||||||
isRunningPrediction: boolean;
|
isRunningPrediction: boolean;
|
||||||
@ -112,7 +112,7 @@ export function HNSLeftPanel({
|
|||||||
}, [loadedParams]);
|
}, [loadedParams]);
|
||||||
|
|
||||||
// 기상정보 자동조회 (사고 발생 일시 기반)
|
// 기상정보 자동조회 (사고 발생 일시 기반)
|
||||||
const weather = useWeatherFetch(incidentCoord.lat, incidentCoord.lon, accidentDate, accidentTime);
|
const weather = useWeatherFetch(incidentCoord?.lat ?? 0, incidentCoord?.lon ?? 0, accidentDate, accidentTime);
|
||||||
|
|
||||||
// 물질 독성 정보
|
// 물질 독성 정보
|
||||||
const tox = getSubstanceToxicity(substance);
|
const tox = getSubstanceToxicity(substance);
|
||||||
@ -272,15 +272,23 @@ export function HNSLeftPanel({
|
|||||||
className="prd-i flex-1 font-mono"
|
className="prd-i flex-1 font-mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={incidentCoord.lat.toFixed(4)}
|
value={incidentCoord?.lat.toFixed(4) ?? ''}
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })}
|
placeholder="위도"
|
||||||
|
onChange={(e) => {
|
||||||
|
const lat = parseFloat(e.target.value) || 0;
|
||||||
|
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="prd-i flex-1 font-mono"
|
className="prd-i flex-1 font-mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.0001"
|
step="0.0001"
|
||||||
value={incidentCoord.lon.toFixed(4)}
|
value={incidentCoord?.lon.toFixed(4) ?? ''}
|
||||||
onChange={(e) => onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })}
|
placeholder="경도"
|
||||||
|
onChange={(e) => {
|
||||||
|
const lon = parseFloat(e.target.value) || 0;
|
||||||
|
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
<button className="prd-map-btn" onClick={onMapSelectClick}>
|
||||||
📍 지도
|
📍 지도
|
||||||
@ -290,7 +298,7 @@ export function HNSLeftPanel({
|
|||||||
{/* DMS 표시 */}
|
{/* DMS 표시 */}
|
||||||
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
||||||
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}>
|
||||||
{toDMS(incidentCoord.lat, 'lat')} / {toDMS(incidentCoord.lon, 'lon')}
|
{incidentCoord ? `${toDMS(incidentCoord.lat, 'lat')} / ${toDMS(incidentCoord.lon, 'lon')}` : '지도에서 위치를 선택하세요'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 유출형태 + 물질명 */}
|
{/* 유출형태 + 물질명 */}
|
||||||
|
|||||||
@ -156,7 +156,7 @@ function DispersionTimeSlider({
|
|||||||
export function HNSView() {
|
export function HNSView() {
|
||||||
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const [incidentCoord, setIncidentCoord] = useState({ lon: 129.3542, lat: 35.4215 });
|
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
|
||||||
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -184,6 +184,7 @@ export function HNSView() {
|
|||||||
setCurrentFrame(0);
|
setCurrentFrame(0);
|
||||||
setIsPuffPlaying(false);
|
setIsPuffPlaying(false);
|
||||||
setInputParams(null);
|
setInputParams(null);
|
||||||
|
setIncidentCoord(null);
|
||||||
hasRunOnce.current = false;
|
hasRunOnce.current = false;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -320,6 +321,11 @@ export function HNSView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = paramsOverride ?? inputParams;
|
const params = paramsOverride ?? inputParams;
|
||||||
|
if (!incidentCoord) {
|
||||||
|
alert('사고 지점을 먼저 지도에서 선택하세요.');
|
||||||
|
setIsRunningPrediction(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
|
||||||
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
|
||||||
@ -694,7 +700,7 @@ export function HNSView() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user