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>
162 lines
5.1 KiB
TypeScript
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)} 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;
|