feat(weather): 기상 정보 기상 레이어 업데이트 #78

병합
dnlee feature/add-weather-alarm 에서 develop 로 10 commits 를 머지했습니다 2026-03-11 11:14:25 +09:00
12개의 변경된 파일847개의 추가작업 그리고 21개의 파일을 삭제
Showing only changes of commit 626fea4c75 - Show all commits

파일 보기

@ -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;

파일 보기

@ -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<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' };
}
}

파일 보기

@ -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)

파일 보기

@ -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/브루트포스 방지
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분 후 다시 시도하세요.'

파일 보기

@ -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<CCTVPlayerHandle, CCTVPlayerProps>(({
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
}: CCTVPlayerProps) {
oilDetectionEnabled = false,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(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<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(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
@ -185,7 +261,7 @@ export function CCTVPlayer({
}
return (
<>
<div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */}
{playerState === 'loading' && (
<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 */}
{streamType === 'mjpeg' && proxiedUrl && (
<img
src={proxiedUrl}
alt={cameraNm}
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}
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({
<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}` : ''}
</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 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<CctvCameraItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
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}</button>
))}
</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>
@ -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)' }}>
{cam ? (
<CCTVPlayer
ref={el => { 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}
/>
) : (
<div className="text-[10px] text-text-3 font-korean opacity-40"> </div>

파일 보기

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

파일 보기

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

파일 보기

@ -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 {
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 });
}}
/>
<input
className="prd-i flex-1 font-mono"
type="number"
step="0.0001"
value={incidentCoord.lon.toFixed(4)}
onChange={(e) => 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 });
}}
/>
<button className="prd-map-btn" onClick={onMapSelectClick}>
📍
@ -290,7 +298,7 @@ export function HNSLeftPanel({
{/* DMS 표시 */}
<div className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
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>
{/* 유출형태 + 물질명 */}

파일 보기

@ -156,7 +156,7 @@ function DispersionTimeSlider({
export function HNSView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('hns');
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 [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -184,6 +184,7 @@ export function HNSView() {
setCurrentFrame(0);
setIsPuffPlaying(false);
setInputParams(null);
setIncidentCoord(null);
hasRunOnce.current = false;
}, []);
@ -320,6 +321,11 @@ export function HNSView() {
try {
const params = paramsOverride ?? inputParams;
if (!incidentCoord) {
alert('사고 지점을 먼저 지도에서 선택하세요.');
setIsRunningPrediction(false);
return;
}
// 1. 계산 먼저 실행 (동기, 히트맵 즉시 표시)
const { tox, meteo, resultForZones, substanceName } = runComputation(params, incidentCoord);
@ -694,7 +700,7 @@ export function HNSView() {
) : (
<>
<MapView
incidentCoord={incidentCoord}
incidentCoord={incidentCoord ?? undefined}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}