From 626fea4c751673cd03e2ea0f7051b4efb8299242 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Fri, 6 Mar 2026 13:31:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(aerial):=20CCTV=20=EC=98=A4=EC=9D=BC=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20GPU=20=EC=B6=94=EB=A1=A0=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20HNS=20=EC=B4=88=EA=B8=B0=20=ED=95=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/aerial/aerialRouter.ts | 42 ++++ backend/src/aerial/aerialService.ts | 59 ++++++ backend/src/inference/oil_inference_server.py | 185 ++++++++++++++++++ backend/src/inference/requirements.txt | 8 + backend/src/server.ts | 6 +- .../src/tabs/aerial/components/CCTVPlayer.tsx | 99 +++++++++- .../src/tabs/aerial/components/CctvView.tsx | 25 ++- .../aerial/components/OilDetectionOverlay.tsx | 161 +++++++++++++++ .../src/tabs/aerial/hooks/useOilDetection.ts | 84 ++++++++ .../src/tabs/aerial/utils/oilDetection.ts | 165 ++++++++++++++++ .../src/tabs/hns/components/HNSLeftPanel.tsx | 24 ++- frontend/src/tabs/hns/components/HNSView.tsx | 10 +- 12 files changed, 847 insertions(+), 21 deletions(-) create mode 100644 backend/src/inference/oil_inference_server.py create mode 100644 backend/src/inference/requirements.txt create mode 100644 frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx create mode 100644 frontend/src/tabs/aerial/hooks/useOilDetection.ts create mode 100644 frontend/src/tabs/aerial/utils/oilDetection.ts diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts index fead1cc..a002e77 100644 --- a/backend/src/aerial/aerialRouter.ts +++ b/backend/src/aerial/aerialRouter.ts @@ -7,6 +7,8 @@ import { createSatRequest, updateSatRequestStatus, isValidSatStatus, + requestOilInference, + checkInferenceHealth, } from './aerialService.js'; import { isValidNumber } from '../middleware/security.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; diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index f9db73e..aec19b9 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -339,3 +339,62 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis [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 { + 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' }; + } +} diff --git a/backend/src/inference/oil_inference_server.py b/backend/src/inference/oil_inference_server.py new file mode 100644 index 0000000..7cd138f --- /dev/null +++ b/backend/src/inference/oil_inference_server.py @@ -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) diff --git a/backend/src/inference/requirements.txt b/backend/src/inference/requirements.txt new file mode 100644 index 0000000..757006d --- /dev/null +++ b/backend/src/inference/requirements.txt @@ -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 diff --git a/backend/src/server.ts b/backend/src/server.ts index 6efd807..8a7c819 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -97,9 +97,13 @@ app.use(cors({ // 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지 const generalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 - max: 200, // IP당 최대 200요청 + max: 500, // IP당 최대 500요청 (HLS 스트리밍 고려) standardHeaders: true, legacyHeaders: false, + skip: (req) => { + // HLS 스트리밍 프록시는 빈번한 세그먼트 요청이 발생하므로 제외 + return req.path.startsWith('/api/aerial/cctv/stream-proxy'); + }, message: { error: '요청 횟수 초과', message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.' diff --git a/frontend/src/tabs/aerial/components/CCTVPlayer.tsx b/frontend/src/tabs/aerial/components/CCTVPlayer.tsx index 558de05..60e99be 100644 --- a/frontend/src/tabs/aerial/components/CCTVPlayer.tsx +++ b/frontend/src/tabs/aerial/components/CCTVPlayer.tsx @@ -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 { detectStreamType } from '../utils/streamUtils'; +import { useOilDetection } from '../hooks/useOilDetection'; +import OilDetectionOverlay from './OilDetectionOverlay'; interface CCTVPlayerProps { cameraNm: string; @@ -9,6 +11,11 @@ interface CCTVPlayerProps { coordDc?: string | null; sourceNm?: string | null; cellIndex?: number; + oilDetectionEnabled?: boolean; +} + +export interface CCTVPlayerHandle { + capture: () => void; } type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url'; @@ -21,15 +28,17 @@ function toProxyUrl(url: string): string { return url; } -export function CCTVPlayer({ +export const CCTVPlayer = forwardRef(({ cameraNm, streamUrl, sttsCd, coordDc, sourceNm, cellIndex = 0, -}: CCTVPlayerProps) { + oilDetectionEnabled = false, +}, ref) => { const videoRef = useRef(null); + const containerRef = useRef(null); const hlsRef = useRef(null); const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading'); const [retryKey, setRetryKey] = useState(0); @@ -56,6 +65,73 @@ export function CCTVPlayer({ ? 'playing' : 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('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(() => { if (hlsRef.current) { hlsRef.current.destroy(); @@ -185,7 +261,7 @@ export function CCTVPlayer({ } return ( - <> +
{/* 로딩 오버레이 */} {playerState === 'loading' && (
@@ -207,13 +283,18 @@ export function CCTVPlayer({ /> )} + {/* 오일 감지 오버레이 */} + {oilDetectionEnabled && ( + + )} + {/* MJPEG */} {streamType === 'mjpeg' && proxiedUrl && ( {cameraNm} setPlayerState('error')} + onError={() => setHlsPlayerState('error')} /> )} @@ -224,7 +305,7 @@ export function CCTVPlayer({ title={cameraNm} className="absolute inset-0 w-full h-full border-none" allow="autoplay; encrypted-media" - onError={() => setPlayerState('error')} + onError={() => setHlsPlayerState('error')} /> )} @@ -245,6 +326,8 @@ export function CCTVPlayer({
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
- +
); -} +}); + +CCTVPlayer.displayName = 'CCTVPlayer'; diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index 5ba929f..6617e11 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -1,7 +1,8 @@ -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useRef } from 'react' import { fetchCctvCameras } from '../services/aerialApi' import type { CctvCameraItem } from '../services/aerialApi' import { CCTVPlayer } from './CCTVPlayer' +import type { CCTVPlayerHandle } from './CCTVPlayer' /** KHOA HLS 스트림 베이스 URL */ const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; @@ -54,6 +55,8 @@ export function CctvView() { const [selectedCamera, setSelectedCamera] = useState(null) const [gridMode, setGridMode] = useState(1) const [activeCells, setActiveCells] = useState([]) + const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false) + const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) const loadData = useCallback(async () => { setLoading(true) @@ -226,7 +229,23 @@ export function CctvView() { >{g.icon} ))}
- + + @@ -242,12 +261,14 @@ export function CctvView() {
{cam ? ( { playerRefs.current[i] = el }} cameraNm={cam.cameraNm} streamUrl={cam.streamUrl} sttsCd={cam.sttsCd} coordDc={cam.coordDc} sourceNm={cam.sourceNm} cellIndex={i} + oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9} /> ) : (
카메라를 선택하세요
diff --git a/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx b/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx new file mode 100644 index 0000000..6568ba0 --- /dev/null +++ b/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx @@ -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 = { + 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(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 ( + <> + + + {/* OSD — bottom-8로 좌표 OSD(bottom-2)와 겹침 방지 */} +
+ {/* 에러 표시 */} + {error && ( +
+ 추론 서버 연결 불가 +
+ )} + + {/* 클래스별 감지 결과 */} + {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 ( +
+ {label}: {formatArea(region.areaM2)} ({region.percentage.toFixed(1)}%) +
+ ); + })} + {/* 합계 */} +
+ 합계: {formatArea(result.totalAreaM2)} ({result.totalPercentage.toFixed(1)}%) +
+ + )} + + {/* 감지 없음 */} + {!hasRegions && !isAnalyzing && !error && ( +
+ 감지 없음 +
+ )} + + {/* 분석 중 */} + {isAnalyzing && ( + + 분석중... + + )} +
+ + ); +}); + +OilDetectionOverlay.displayName = 'OilDetectionOverlay'; + +export default OilDetectionOverlay; diff --git a/frontend/src/tabs/aerial/hooks/useOilDetection.ts b/frontend/src/tabs/aerial/hooks/useOilDetection.ts new file mode 100644 index 0000000..514f6fd --- /dev/null +++ b/frontend/src/tabs/aerial/hooks/useOilDetection.ts @@ -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; + enabled: boolean; + config?: Partial; +} + +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(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(null); + + const configRef = useRef({ + ...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 }; +} diff --git a/frontend/src/tabs/aerial/utils/oilDetection.ts b/frontend/src/tabs/aerial/utils/oilDetection.ts new file mode 100644 index 0000000..4e13d8b --- /dev/null +++ b/frontend/src/tabs/aerial/utils/oilDetection.ts @@ -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 = { + 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 { + const imageBase64 = captureFrameAsBase64(video, config.captureWidth); + if (!imageBase64) return null; + + const response = await api.post('/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(), + }; +} diff --git a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx index 1abb617..ee334a6 100755 --- a/frontend/src/tabs/hns/components/HNSLeftPanel.tsx +++ b/frontend/src/tabs/hns/components/HNSLeftPanel.tsx @@ -36,8 +36,8 @@ export interface HNSInputParams { interface HNSLeftPanelProps { activeSubTab: 'analysis' | 'list'; onSubTabChange: (tab: 'analysis' | 'list') => void; - incidentCoord: { lon: number; lat: number }; - onCoordChange: (coord: { lon: number; lat: number }) => void; + incidentCoord: { lon: number; lat: number } | null; + onCoordChange: (coord: { lon: number; lat: number } | null) => void; onMapSelectClick: () => void; onRunPrediction: () => void; isRunningPrediction: boolean; @@ -112,7 +112,7 @@ export function HNSLeftPanel({ }, [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); @@ -272,15 +272,23 @@ export function HNSLeftPanel({ className="prd-i flex-1 font-mono" type="number" step="0.0001" - value={incidentCoord.lat.toFixed(4)} - onChange={(e) => onCoordChange({ ...incidentCoord, lat: parseFloat(e.target.value) || 0 })} + value={incidentCoord?.lat.toFixed(4) ?? ''} + placeholder="위도" + onChange={(e) => { + const lat = parseFloat(e.target.value) || 0; + onCoordChange({ lon: incidentCoord?.lon ?? 0, lat }); + }} /> onCoordChange({ ...incidentCoord, lon: parseFloat(e.target.value) || 0 })} + value={incidentCoord?.lon.toFixed(4) ?? ''} + placeholder="경도" + onChange={(e) => { + const lon = parseFloat(e.target.value) || 0; + onCoordChange({ lat: incidentCoord?.lat ?? 0, lon }); + }} />