diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts index 67aa49a..23c6391 100644 --- a/backend/src/aerial/aerialService.ts +++ b/backend/src/aerial/aerialService.ts @@ -61,7 +61,7 @@ export async function getMediaBySn(sn: number): Promise } export async function fetchOriginalImage(camTy: string, fileId: string): Promise { - const res = await fetch(`${IMAGE_ANALYSIS_URL}/get-original-image/${camTy}/${fileId}`, { + const res = await fetch(`${IMAGE_API_URL}/get-original-image/${camTy}/${fileId}`, { signal: AbortSignal.timeout(30_000), }); if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`); @@ -364,8 +364,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis // OIL INFERENCE (GPU 서버 프록시) // ============================================================ -const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:5001'; -const IMAGE_ANALYSIS_URL = process.env.IMAGE_ANALYSIS_URL || OIL_INFERENCE_URL; +const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001'; const INFERENCE_TIMEOUT_MS = 10_000; export interface OilInferenceRegion { @@ -393,7 +392,7 @@ export async function stitchImages( for (const f of files) { form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname); } - const response = await fetch(`${IMAGE_ANALYSIS_URL}/stitch`, { + const response = await fetch(`${IMAGE_API_URL}/stitch`, { method: 'POST', body: form, signal: AbortSignal.timeout(300_000), @@ -409,9 +408,8 @@ export async function stitchImages( 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`, { + const response = await fetch(`${IMAGE_API_URL}/inference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: imageBase64 }), @@ -432,7 +430,7 @@ export async function requestOilInference(imageBase64: string): Promise { try { - const response = await fetch(`${OIL_INFERENCE_URL}/health`, { + const response = await fetch(`${IMAGE_API_URL}/health`, { signal: AbortSignal.timeout(3000), }); if (!response.ok) throw new Error(`status ${response.status}`); diff --git a/backend/src/prediction/imageAnalyzeService.ts b/backend/src/prediction/imageAnalyzeService.ts index 74790bd..41452e7 100644 --- a/backend/src/prediction/imageAnalyzeService.ts +++ b/backend/src/prediction/imageAnalyzeService.ts @@ -102,7 +102,7 @@ export async function analyzeImageFile(imageBuffer: Buffer, originalName: string if (res.status === 400 && text.includes('GPS')) { throw Object.assign(new Error('GPS_NOT_FOUND'), { code: 'GPS_NOT_FOUND' }); } - throw new Error(`이미지 분석 서버 오류: ${res.status}`); + throw new Error(`이미지 분석 서버 오류: ${res.status} - ${text}`); } serverResponse = await res.json() as ImageServerResponse; diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index 13b2dcc..1e7b7e2 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -161,7 +161,7 @@ interface MapViewProps { incidentCoord?: { lon: number; lat: number } isSelectingLocation?: boolean onMapClick?: (lon: number, lat: number) => void - oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }> + oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel; stranded?: 0 | 1 }> selectedModels?: Set dispersionResult?: DispersionResult | null dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }> diff --git a/frontend/src/common/hooks/useSubMenu.ts b/frontend/src/common/hooks/useSubMenu.ts index 6b1baae..34e8ed6 100755 --- a/frontend/src/common/hooks/useSubMenu.ts +++ b/frontend/src/common/hooks/useSubMenu.ts @@ -175,4 +175,50 @@ export function consumeHnsReportPayload(): HnsReportPayload | null { return v; } +// ─── 유출유 예측 보고서 실 데이터 전달 ────────────────────────── +export interface OilReportPayload { + incident: { + name: string; + occurTime: string; + location: string; + lat: number | null; + lon: number | null; + pollutant: string; + spillAmount: string; + shipName: string; + }; + pollution: { + spillAmount: string; + weathered: string; + seaRemain: string; + pollutionArea: string; + coastAttach: string; + coastLength: string; + oilType: string; + }; + weather: { + windDir: string; + windSpeed: string; + waveHeight: string; + temp: string; + } | null; + spread: { + kosps: string; + openDrift: string; + poseidon: string; + }; + coastal: { + firstTime: string | null; + }; + hasSimulation: boolean; +} + +let _oilReportPayload: OilReportPayload | null = null; +export function setOilReportPayload(d: OilReportPayload | null) { _oilReportPayload = d; } +export function consumeOilReportPayload(): OilReportPayload | null { + const v = _oilReportPayload; + _oilReportPayload = null; + return v; +} + export { subMenuState } diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index 9057034..10ed147 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -8,12 +8,12 @@ import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView' import { BacktrackModal } from './BacktrackModal' import { RecalcModal } from './RecalcModal' import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' -import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' +import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' -import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' +import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, SimulationRunResponse, SimulationSummary, WindPoint } from '../services/predictionApi' import { useSimulationStatus } from '../hooks/useSimulationStatus' import SimulationLoadingOverlay from './SimulationLoadingOverlay' import { api } from '@common/services/api' @@ -109,7 +109,7 @@ export function OilSpillView() { const flyToTarget = null const fitBoundsTarget = null const [isSelectingLocation, setIsSelectingLocation] = useState(false) - const [oilTrajectory, setOilTrajectory] = useState>([]) + const [oilTrajectory, setOilTrajectory] = useState([]) const [centerPoints, setCenterPoints] = useState([]) const [windData, setWindData] = useState([]) const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) @@ -604,6 +604,62 @@ export function OilSpillView() { } } + const handleOpenReport = () => { + const OIL_TYPE_CODE: Record = { + '벙커C유': 'BUNKER_C', '경유': 'DIESEL', '원유': 'CRUDE_OIL', '윤활유': 'LUBE_OIL', + }; + const accidentName = + selectedAnalysis?.acdntNm || + analysisDetail?.acdnt?.acdntNm || + incidentName || + '(미입력)'; + const occurTime = + selectedAnalysis?.occurredAt || + analysisDetail?.acdnt?.occurredAt || + accidentTime || + ''; + const wx = analysisDetail?.weather?.[0] ?? null; + + const payload: OilReportPayload = { + incident: { + name: accidentName, + occurTime, + location: selectedAnalysis?.location || analysisDetail?.acdnt?.location || '', + lat: incidentCoord?.lat ?? selectedAnalysis?.lat ?? null, + lon: incidentCoord?.lon ?? selectedAnalysis?.lon ?? null, + pollutant: OIL_TYPE_CODE[oilType] || oilType, + spillAmount: `${spillAmount} ${spillUnit}`, + shipName: analysisDetail?.vessels?.[0]?.vesselNm || '', + }, + pollution: { + spillAmount: `${spillAmount.toFixed(2)} ${spillUnit}`, + weathered: simulationSummary ? `${simulationSummary.weatheredVolume.toFixed(2)} m³` : '—', + seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)} m³` : '—', + pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—', + coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)} m³` : '—', + coastLength: simulationSummary ? `${simulationSummary.pollutionCoastLength.toFixed(2)} km` : '—', + oilType: OIL_TYPE_CODE[oilType] || oilType, + }, + weather: wx + ? { windDir: wx.wind, windSpeed: wx.wind, waveHeight: wx.wave, temp: wx.temp } + : null, + spread: { kosps: '—', openDrift: '—', poseidon: '—' }, + coastal: { + firstTime: (() => { + const beachedTimes = oilTrajectory.filter(p => p.stranded === 1).map(p => p.time); + if (beachedTimes.length === 0) return null; + const d = new Date(Math.min(...beachedTimes) * 1000); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + })(), + }, + hasSimulation: simulationSummary !== null, + }; + + setOilReportPayload(payload); + setReportGenCategory(0); + navigateToTab('reports', 'generate'); + }; + return (
{/* Left Sidebar */} @@ -876,7 +932,7 @@ export function OilSpillView() {
{/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} summary={simulationSummary} />} + {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />} {/* 확산 예측 실행 중 로딩 오버레이 */} {isRunningSimulation && ( diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index bb9cd2a..9e780fd 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { createEmptyReport, } from './OilSpillReportTemplate'; -import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload } from '@common/hooks/useSubMenu'; +import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'; import { saveReport } from '../services/reportsApi'; import { CATEGORIES, @@ -32,6 +32,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { // HNS 실 데이터 (없으면 sampleHnsData fallback) const [hnsPayload, setHnsPayload] = useState(null) + // OIL 실 데이터 (없으면 sampleOilData fallback) + const [oilPayload, setOilPayload] = useState(null) // 외부에서 카테고리 힌트가 변경되면 반영 useEffect(() => { @@ -44,6 +46,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { // HNS 데이터 소비 const payload = consumeHnsReportPayload() if (payload) setHnsPayload(payload) + // OIL 예측 데이터 소비 + const oilData = consumeOilReportPayload() + if (oilData) setOilPayload(oilData) }, []) const cat = CATEGORIES[activeCat] @@ -65,8 +70,19 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { report.status = '완료' report.author = '시스템 자동생성' if (activeCat === 0) { - report.incident.pollutant = sampleOilData.pollution.oilType - report.incident.spillAmount = sampleOilData.pollution.spillAmount + if (oilPayload) { + report.incident.name = oilPayload.incident.name; + report.incident.occurTime = oilPayload.incident.occurTime; + report.incident.location = oilPayload.incident.location; + report.incident.lat = String(oilPayload.incident.lat ?? ''); + report.incident.lon = String(oilPayload.incident.lon ?? ''); + report.incident.shipName = oilPayload.incident.shipName; + report.incident.pollutant = oilPayload.pollution.oilType; + report.incident.spillAmount = oilPayload.pollution.spillAmount; + } else { + report.incident.pollutant = sampleOilData.pollution.oilType; + report.incident.spillAmount = sampleOilData.pollution.spillAmount; + } } try { await saveReport(report) @@ -82,6 +98,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { const sectionHTML = activeSections.map(sec => { let content = `

${sec.desc}

`; + // OIL 섹션에 실 데이터 삽입 + if (activeCat === 0 && oilPayload) { + if (sec.id === 'oil-pollution') { + const rows = [ + ['유출량', oilPayload.pollution.spillAmount, '풍화량', oilPayload.pollution.weathered], + ['해상잔유량', oilPayload.pollution.seaRemain, '오염해역면적', oilPayload.pollution.pollutionArea], + ['연안부착량', oilPayload.pollution.coastAttach, '오염해안길이', oilPayload.pollution.coastLength], + ]; + const simBanner = !oilPayload.hasSimulation + ? '

시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.

' + : ''; + const trs = rows.map(r => + `${r[0]}${r[1]}${r[2]}${r[3]}` + ).join(''); + content = `${simBanner}${trs}
`; + } + } + // HNS 섹션에 실 데이터 삽입 if (activeCat === 1 && hnsPayload) { if (sec.id === 'hns-atm') { @@ -261,9 +295,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
{[ - { label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' }, - { label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' }, - { label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' }, + { label: 'KOSPS', value: oilPayload?.spread.kosps || sampleOilData.spread.kosps, color: '#06b6d4' }, + { label: 'OpenDrift', value: oilPayload?.spread.openDrift || sampleOilData.spread.openDrift, color: '#ef4444' }, + { label: 'POSEIDON', value: oilPayload?.spread.poseidon || sampleOilData.spread.poseidon, color: '#f97316' }, ].map((m, i) => (

{m.label}

@@ -274,23 +308,30 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )} {sec.id === 'oil-pollution' && ( - - - - {[ - ['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered], - ['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea], - ['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength], - ].map((row, i) => ( - - - - - - - ))} - -
{row[0]}{row[1]}{row[2]}{row[3]}
+ <> + {oilPayload && !oilPayload.hasSimulation && ( +
+ 시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다. +
+ )} + + + + {[ + ['유출량', oilPayload?.pollution.spillAmount || sampleOilData.pollution.spillAmount, '풍화량', oilPayload?.pollution.weathered || sampleOilData.pollution.weathered], + ['해상잔유량', oilPayload?.pollution.seaRemain || sampleOilData.pollution.seaRemain, '오염해역면적', oilPayload?.pollution.pollutionArea || sampleOilData.pollution.pollutionArea], + ['연안부착량', oilPayload?.pollution.coastAttach || sampleOilData.pollution.coastAttach, '오염해안길이', oilPayload?.pollution.coastLength || sampleOilData.pollution.coastLength], + ].map((row, i) => ( + + + + + + + ))} + +
{row[0]}{row[1]}{row[2]}{row[3]}
+ )} {sec.id === 'oil-sensitive' && ( <> @@ -304,9 +345,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { )} {sec.id === 'oil-coastal' && (

- 최초 부착시간: {sampleOilData.coastal.firstTime} + 최초 부착시간: {oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime} {' / '} - 부착 해안길이: {sampleOilData.coastal.coastLength} + 부착 해안길이: {oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}

)} {sec.id === 'oil-defense' && ( @@ -318,11 +359,20 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)} {sec.id === 'oil-tide' && ( -

- 고조: {sampleOilData.tide.highTide1} - {' / '}저조: {sampleOilData.tide.lowTide} - {' / '}고조: {sampleOilData.tide.highTide2} -

+ <> +

+ 고조: {sampleOilData.tide.highTide1} + {' / '}저조: {sampleOilData.tide.lowTide} + {' / '}고조: {sampleOilData.tide.highTide2} +

+ {oilPayload?.weather && ( +

+ 기상: 풍향/풍속 {oilPayload.weather.windDir} + {' / '}파고 {oilPayload.weather.waveHeight} + {' / '}기온 {oilPayload.weather.temp} +

+ )} + )} {/* ── HNS 대기확산 섹션들 ── */} diff --git a/prediction/image/DOCKER_USAGE.md b/prediction/image/DOCKER_USAGE.md index a703f0d..7f9d6d3 100644 --- a/prediction/image/DOCKER_USAGE.md +++ b/prediction/image/DOCKER_USAGE.md @@ -16,6 +16,7 @@ 8. [로그 확인 및 디버깅](#8-로그-확인-및-디버깅) 9. [컨테이너 관리](#9-컨테이너-관리) 10. [주의사항](#10-주의사항) +11. [CPU 전용 환경 실행](#11-cpu-전용-환경-실행) --- @@ -278,10 +279,10 @@ docker system prune -f ## 10. 주의사항 -### GPU 필수 -- AI 모델(`epoch_165.pth`)은 `cuda:0` 디바이스로 로드된다. -- GPU 없이 실행하면 서버 기동 시 오류가 발생한다. -- CPU 전용 환경에서 테스트하려면 `Inference.py`의 `device='cuda:0'`을 `device='cpu'`로 수정해야 한다. +### GPU 자동 감지 +- 서버 기동 시 `torch.cuda.is_available()`로 GPU 유무를 자동 감지한다. +- GPU가 있으면 `cuda:0`, 없으면 `cpu`로 자동 폴백된다. +- 환경변수 `DEVICE`로 device를 명시 지정할 수 있다 (예: `DEVICE=cpu`, `DEVICE=cuda:1`). ### 첫 기동 시간 - AI 모델 로드: 약 **10~30초** 소요 (GPU 메모리에 로딩) @@ -297,3 +298,79 @@ docker system prune -f ports: - "5002:5001" # 호스트 5002 → 컨테이너 5001 ``` + +--- + +## 11. CPU 전용 환경 실행 + +GPU(NVIDIA)가 없는 환경에서는 CPU 전용 설정을 사용한다. + +### 사전 요구사항 (CPU 모드) + +| 항목 | 최소 버전 | 확인 명령어 | +|------|----------|-------------| +| Docker Engine | 24.0 이상 | `docker --version` | +| Docker Compose | 2.20 이상 | `docker compose version` | +| NVIDIA 드라이버 | **불필요** | — | + +### 빠른 시작 (CPU) + +```bash +# prediction/image/ 디렉토리로 이동 +cd prediction/image + +# 환경변수 파일 준비 (필요 시) +cp .env.example .env + +# CPU 이미지 빌드 + 실행 +docker compose -f docker-compose.cpu.yml up -d --build + +# 서버 상태 확인 +curl http://localhost:5001/docs +``` + +### 빌드 명령어 (CPU) + +```bash +# CPU 이미지만 빌드 +docker compose -f docker-compose.cpu.yml build + +# 캐시 없이 빌드 +docker compose -f docker-compose.cpu.yml build --no-cache +``` + +> **참고**: CPU 기반 PyTorch 이미지는 GPU 이미지(~8GB) 대비 약 70% 용량이 절감된다. +> 단, CPU 추론은 GPU 대비 처리 속도가 느리므로 대용량 이미지 분석 시 시간이 더 소요된다. + +### 실행 명령어 (CPU) + +```bash +# 백그라운드 실행 +docker compose -f docker-compose.cpu.yml up -d + +# 포그라운드 실행 (로그 바로 출력) +docker compose -f docker-compose.cpu.yml up + +# 중지 +docker compose -f docker-compose.cpu.yml down +``` + +### 로컬 직접 실행 (Docker 없이) + +```bash +# GPU 있으면 자동으로 cuda:0 사용, 없으면 cpu로 폴백 +python api.py + +# device 강제 지정 +DEVICE=cpu python api.py +DEVICE=cuda:1 python api.py +``` + +### GPU/CPU 모드 확인 + +서버 기동 로그에서 사용 device를 확인할 수 있다: + +``` +[Inference] 사용 device: cpu ← CPU 모드 +[Inference] 사용 device: cuda:0 ← GPU 모드 +``` diff --git a/prediction/image/Dockerfile b/prediction/image/Dockerfile index a1d8f0d..6da64a5 100644 --- a/prediction/image/Dockerfile +++ b/prediction/image/Dockerfile @@ -1,11 +1,12 @@ # ============================================================================== # wing-image-analysis — 드론 영상 유류 분석 FastAPI 서버 # -# Base: PyTorch 2.1 + CUDA 12.1 + cuDNN 8 (devel 빌드 — GDAL 컴파일 필요) +# Base: PyTorch 1.9.1 + CUDA 11.1 + cuDNN 8 +# (mmsegmentation 0.25.0 / mmcv-full 1.4.3 호환 환경) # GPU: NVIDIA GPU 필수 (MMSegmentation 추론) # Port: 5001 # ============================================================================== -FROM pytorch/pytorch:2.1.0-cuda12.1-cudnn8-devel +FROM pytorch/pytorch:1.9.1-cuda11.1-cudnn8-devel ENV DEBIAN_FRONTEND=noninteractive \ PYTHONDONTWRITEBYTECODE=1 \ @@ -32,6 +33,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # rasterio는 GDAL 헤더 버전을 맞춰 빌드해야 한다 ENV GDAL_VERSION=3.4.1 +# ------------------------------------------------------------------------------ +# mmcv-full 1.4.3 — CUDA 11.1 + PyTorch 1.9.0 pre-built 휠 +# (소스 컴파일 없이 수 초 내 설치) +# ------------------------------------------------------------------------------ +RUN pip install --no-cache-dir \ + mmcv-full==1.4.3 \ + -f https://download.openmmlab.com/mmcv/dist/cu111/torch1.9.0/index.html + # ------------------------------------------------------------------------------ # Python 의존성 설치 # ------------------------------------------------------------------------------ diff --git a/prediction/image/Dockerfile.cpu b/prediction/image/Dockerfile.cpu new file mode 100644 index 0000000..dacdec2 --- /dev/null +++ b/prediction/image/Dockerfile.cpu @@ -0,0 +1,112 @@ +# ============================================================================== +# wing-image-analysis — 드론 영상 유류 분석 FastAPI 서버 (CPU 전용) +# +# Base: python:3.9-slim + PyTorch 1.9.0 CPU 빌드 +# (mmsegmentation 0.25.0 / mmcv-full 1.4.3 호환 환경) +# python:3.9 필수 — numpy 1.26.4, geopandas 0.14.4가 Python >=3.9 요구 +# GPU: 불필요 (CPU 추론) +# Port: 5001 +# ============================================================================== +FROM python:3.9-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DEVICE=cpu + +WORKDIR /app + +# ------------------------------------------------------------------------------ +# 시스템 패키지: GDAL / PROJ / GEOS (rasterio, geopandas 빌드 의존성) +# libspatialindex-dev: geopandas 공간 인덱스 +# opencv-contrib-python-headless 런타임 SO 의존성 (python:3.9-slim에 미포함): +# libgl1 — libGL.so.1 +# libglib2.0-0 — libgthread-2.0.so.0, libgobject-2.0.so.0, libglib-2.0.so.0 +# libsm6 — libSM.so.6 +# libxext6 — libXext.so.6 +# libxrender1 — libXrender.so.1 +# libgomp1 — libgomp.so.1 (OpenMP, numpy/opencv 병렬 처리) +# ------------------------------------------------------------------------------ +RUN apt-get update && apt-get install -y --no-install-recommends \ + gdal-bin \ + libgdal-dev \ + libproj-dev \ + libgeos-dev \ + libspatialindex-dev \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender1 \ + libgomp1 \ + gcc \ + g++ \ + git \ + && rm -rf /var/lib/apt/lists/* + +# rasterio는 GDAL 헤더 버전을 맞춰 빌드해야 한다 +ENV GDAL_VERSION=3.4.1 + +# ------------------------------------------------------------------------------ +# GDAL Python 바인딩 (osgeo 모듈) — 시스템 GDAL 버전과 일치해야 한다 +# python:3.9-slim은 conda 없이 pip 환경이므로 명시적 설치 필요 +# ------------------------------------------------------------------------------ +RUN pip install --no-cache-dir GDAL=="$(gdal-config --version)" + +# ------------------------------------------------------------------------------ +# PyTorch 1.9.0 CPU 버전 설치 +# (mmsegmentation 0.25.0 / mmcv-full 1.4.3 호환) +# ------------------------------------------------------------------------------ +RUN pip install --no-cache-dir \ + torch==1.9.0+cpu \ + torchvision==0.10.0+cpu \ + -f https://download.pytorch.org/whl/torch_stable.html + +# ------------------------------------------------------------------------------ +# mmcv-full 1.4.3 CPU 휠 (CUDA ops 없는 경량 빌드, 추론에 충분) +# ------------------------------------------------------------------------------ +RUN pip install --no-cache-dir \ + mmcv-full==1.4.3 \ + -f https://download.openmmlab.com/mmcv/dist/cpu/torch1.9.0/index.html + +# ------------------------------------------------------------------------------ +# Python 의존성 설치 +# ------------------------------------------------------------------------------ +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ------------------------------------------------------------------------------ +# 로컬 mmsegmentation 설치 (mx15hdi/Detect/mmsegmentation/) +# 번들 소스를 먼저 복사한 뒤 editable 설치한다 +# ------------------------------------------------------------------------------ +COPY mx15hdi/Detect/mmsegmentation/ /tmp/mmsegmentation/ +RUN pip install --no-cache-dir -e /tmp/mmsegmentation/ + +# ------------------------------------------------------------------------------ +# 소스 코드 전체 복사 +# 대용량 데이터 디렉토리(Original_Images, result 등)는 +# docker-compose.cpu.yml의 볼륨 마운트로 외부에서 주입된다 +# ------------------------------------------------------------------------------ +COPY . . + +# ------------------------------------------------------------------------------ +# .dockerignore로 제외된 런타임 출력 디렉토리를 빈 폴더로 생성 +# (볼륨 마운트 전에도 경로가 존재해야 한다) +# ------------------------------------------------------------------------------ +RUN mkdir -p \ + /app/stitch \ + /app/mx15hdi/Detect/Mask_result \ + /app/mx15hdi/Detect/result \ + /app/mx15hdi/Georeference/Mask_Tif \ + /app/mx15hdi/Georeference/Tif \ + /app/mx15hdi/Metadata/CSV \ + /app/mx15hdi/Metadata/Image/Original_Images \ + /app/mx15hdi/Polygon/Shp + +# ------------------------------------------------------------------------------ +# 런타임 설정 +# ------------------------------------------------------------------------------ +EXPOSE 5001 + +# workers=1: 모델을 프로세스 하나에서만 로드 (메모리 공유 불가) +CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "5001", "--workers", "1"] diff --git a/prediction/image/api.py b/prediction/image/api.py index 77fb91b..1a927e4 100644 --- a/prediction/image/api.py +++ b/prediction/image/api.py @@ -180,6 +180,8 @@ async def run_script( except subprocess.TimeoutExpired: raise HTTPException(status_code=500, detail="Script execution timed out") except Exception as e: + import traceback + traceback.print_exc() raise HTTPException(status_code=500, detail=str(e)) diff --git a/prediction/image/docker-compose.cpu.yml b/prediction/image/docker-compose.cpu.yml new file mode 100644 index 0000000..4b1e555 --- /dev/null +++ b/prediction/image/docker-compose.cpu.yml @@ -0,0 +1,46 @@ +version: "3.9" + +# CPU 전용 docker-compose 설정 +# GPU(nvidia-container-toolkit) 없이도 실행 가능 +# 실행: docker compose -f docker-compose.cpu.yml up -d --build + +services: + image-analysis: + build: + context: . + dockerfile: Dockerfile.cpu + image: wing-image-analysis:cpu + container_name: wing-image-analysis + ports: + - "5001:5001" + environment: + - DEVICE=cpu + + volumes: + # ── mx15hdi (EO 드론 카메라) ──────────────────────────────────────── + # 입력: 업로드된 원본 이미지 + - ./mx15hdi/Metadata/Image/Original_Images:/app/mx15hdi/Metadata/Image/Original_Images + # 출력: 메타데이터 CSV + - ./mx15hdi/Metadata/CSV:/app/mx15hdi/Metadata/CSV + # 출력: 지리참조 GeoTIFF (컬러 / 마스크) + - ./mx15hdi/Georeference/Tif:/app/mx15hdi/Georeference/Tif + - ./mx15hdi/Georeference/Mask_Tif:/app/mx15hdi/Georeference/Mask_Tif + # 출력: 유류 폴리곤 Shapefile + - ./mx15hdi/Polygon/Shp:/app/mx15hdi/Polygon/Shp + # 출력: 블렌딩 추론 결과 / 마스크 이미지 + - ./mx15hdi/Detect/result:/app/mx15hdi/Detect/result + - ./mx15hdi/Detect/Mask_result:/app/mx15hdi/Detect/Mask_result + # ── starsafire (열화상 카메라) ────────────────────────────────────── + - ./starsafire/Metadata/Image/Original_Images:/app/starsafire/Metadata/Image/Original_Images + - ./starsafire/Metadata/CSV:/app/starsafire/Metadata/CSV + - ./starsafire/Georeference/Tif:/app/starsafire/Georeference/Tif + - ./starsafire/Georeference/Mask_Tif:/app/starsafire/Georeference/Mask_Tif + - ./starsafire/Polygon/Shp:/app/starsafire/Polygon/Shp + - ./starsafire/Detect/result:/app/starsafire/Detect/result + - ./starsafire/Detect/Mask_result:/app/starsafire/Detect/Mask_result + # ── 스티칭 결과 ───────────────────────────────────────────────────── + - ./stitch:/app/stitch + + # GPU deploy 섹션 없음 — CPU 전용 실행 + + restart: unless-stopped diff --git a/prediction/image/docker-compose.yml b/prediction/image/docker-compose.yml index 671aad5..125eacf 100644 --- a/prediction/image/docker-compose.yml +++ b/prediction/image/docker-compose.yml @@ -9,7 +9,6 @@ services: container_name: wing-image-analysis ports: - "5001:5001" - env_file: .env volumes: # ── mx15hdi (EO 드론 카메라) ──────────────────────────────────────── diff --git a/prediction/image/mx15hdi/Detect/Inference.py b/prediction/image/mx15hdi/Detect/Inference.py index 333da12..52aa18f 100644 --- a/prediction/image/mx15hdi/Detect/Inference.py +++ b/prediction/image/mx15hdi/Detect/Inference.py @@ -1,5 +1,6 @@ import os, mmcv, cv2, json import numpy as np +import torch from pathlib import Path from PIL import Image from tqdm import tqdm @@ -13,9 +14,19 @@ _MX15HDI_DIR = _DETECT_DIR.parent # mx15hdi/ def load_model(): """서버 시작 시 1회 호출. 로드된 모델 객체를 반환한다.""" + # 우선순위: 환경변수 DEVICE > GPU 자동감지 > CPU 폴백 + env_device = os.environ.get('DEVICE', '').strip() + if env_device: + device = env_device + elif torch.cuda.is_available(): + device = 'cuda:0' + else: + device = 'cpu' + print(f'[Inference] 사용 device: {device}') + config = str(_DETECT_DIR / 'V7_SPECIAL.py') checkpoint = str(_DETECT_DIR / 'epoch_165.pth') - model = init_segmentor(config, checkpoint, device='cuda:0') + model = init_segmentor(config, checkpoint, device=device) model.PALETTE = [ [0, 0, 0], # background [0, 0, 204], # black diff --git a/prediction/image/requirements.txt b/prediction/image/requirements.txt index b700740..c0f9f97 100644 --- a/prediction/image/requirements.txt +++ b/prediction/image/requirements.txt @@ -4,18 +4,26 @@ uvicorn[standard]==0.29.0 # 이미지 처리 numpy==1.26.4 -opencv-python-headless==4.9.0.80 +# opencv-contrib-python-headless: headless(GUI 불필요) + contrib(Stitcher 등) 통합 +opencv-contrib-python-headless==4.9.0.80 Pillow==10.3.0 piexif==1.1.3 +scikit-image==0.19.3 +matplotlib==3.5.1 # 지리 데이터 처리 rasterio==1.3.10 geopandas==0.14.4 shapely==2.0.4 pyproj==3.6.1 +# osgeo(GDAL Python 바인딩)는 시스템 GDAL 버전과 맞춰야 하므로 Dockerfile에서 설치 -# AI/ML — PyTorch는 base 이미지에 포함, mmsegmentation은 로컬 소스에서 설치 -mmcv==2.1.0 +# AI/ML — PyTorch는 base 이미지에 포함, mmcv/mmsegmentation은 Dockerfile에서 설치 +# mmcv-full==1.4.3 은 torch/CUDA 버전에 맞는 pre-built 휠이 필요하여 Dockerfile에서 직접 설치 + +# OCR (메타데이터 추출: Export_Metadata_mx15hdi.py) +paddlepaddle==2.6.2 +paddleocr==2.7.0.2 # 유틸리티 pandas==2.2.2