wing-ops/frontend/src/tabs/aerial/components/OilDetectionOverlay.tsx
Nan Kyung Lee 626fea4c75 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>
2026-03-06 13:31:02 +09:00

162 lines
5.1 KiB
TypeScript

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;