feat(prediction): CPU 전용 Docker 환경 구축 + 이미지 분석/보고서/항공 UI 개선

This commit is contained in:
jeonghyo.k 2026-03-11 17:30:25 +09:00
부모 4300191000
커밋 54d3a281c6
14개의 변경된 파일468개의 추가작업 그리고 54개의 파일을 삭제

파일 보기

@ -61,7 +61,7 @@ export async function getMediaBySn(sn: number): Promise<AerialMediaItem | null>
}
export async function fetchOriginalImage(camTy: string, fileId: string): Promise<Buffer> {
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<OilInferenceResult> {
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<OilInfer
/** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
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}`);

파일 보기

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

파일 보기

@ -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<PredictionModel>
dispersionResult?: DispersionResult | null
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>

파일 보기

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

파일 보기

@ -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<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
const [windData, setWindData] = useState<WindPoint[][]>([])
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([])
@ -604,6 +604,62 @@ export function OilSpillView() {
}
}
const handleOpenReport = () => {
const OIL_TYPE_CODE: Record<string, string> = {
'벙커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)}` : '—',
seaRemain: simulationSummary ? `${simulationSummary.remainingVolume.toFixed(2)}` : '—',
pollutionArea: simulationSummary ? `${simulationSummary.pollutionArea.toFixed(2)} km²` : '—',
coastAttach: simulationSummary ? `${simulationSummary.beachedVolume.toFixed(2)}` : '—',
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 (
<div className="relative flex flex-1 overflow-hidden">
{/* Left Sidebar */}
@ -876,7 +932,7 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} summary={simulationSummary} />}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={handleOpenReport} detail={analysisDetail} summary={simulationSummary} />}
{/* 확산 예측 실행 중 로딩 오버레이 */}
{isRunningSimulation && (

파일 보기

@ -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<HnsReportPayload | null>(null)
// OIL 실 데이터 (없으면 sampleOilData fallback)
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(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 = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`;
// 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
? '<p style="font-size:10px;color:#f97316;margin-bottom:8px;">시뮬레이션이 실행되지 않아 오염량은 입력값 기준으로 표시됩니다.</p>'
: '';
const trs = rows.map(r =>
`<tr><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[0]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[1]}</td><td style="padding:6px 8px;border:1px solid #ddd;color:#888;">${r[2]}</td><td style="padding:6px 8px;border:1px solid #ddd;font-weight:bold;text-align:right;">${r[3]}</td></tr>`
).join('');
content = `${simBanner}<table style="width:100%;border-collapse:collapse;font-size:12px;">${trs}</table>`;
}
}
// HNS 섹션에 실 데이터 삽입
if (activeCat === 1 && hnsPayload) {
if (sec.id === 'hns-atm') {
@ -261,9 +295,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ 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) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center">
<p className="text-[10px] text-text-3 font-korean mb-1">{m.label}</p>
@ -274,23 +308,30 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</>
)}
{sec.id === 'oil-pollution' && (
<table className="w-full table-fixed border-collapse">
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
<tbody>
{[
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered],
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea],
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength],
].map((row, i) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
</tr>
))}
</tbody>
</table>
<>
{oilPayload && !oilPayload.hasSimulation && (
<div className="mb-3 px-3 py-2 rounded text-[10px] font-korean" style={{ background: 'rgba(249,115,22,0.08)', border: '1px solid rgba(249,115,22,0.3)', color: '#f97316' }}>
.
</div>
)}
<table className="w-full table-fixed border-collapse">
<colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
<tbody>
{[
['유출량', 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) => (
<tr key={i} className="border-b border-border">
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[1]}</td>
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td>
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td>
</tr>
))}
</tbody>
</table>
</>
)}
{sec.id === 'oil-sensitive' && (
<>
@ -304,9 +345,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)}
{sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.coastal.firstTime}</span>
: <span className="font-semibold text-text-1">{oilPayload?.coastal?.firstTime ?? sampleOilData.coastal.firstTime}</span>
{' / '}
: <span className="font-semibold text-text-1">{sampleOilData.coastal.coastLength}</span>
: <span className="font-semibold text-text-1">{oilPayload?.pollution.coastLength || sampleOilData.coastal.coastLength}</span>
</p>
)}
{sec.id === 'oil-defense' && (
@ -318,11 +359,20 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div>
)}
{sec.id === 'oil-tide' && (
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
</p>
<>
<p className="text-[12px] text-text-2 font-korean">
: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span>
</p>
{oilPayload?.weather && (
<p className="text-[11px] text-text-3 font-korean mt-2">
기상: 풍향/ <span className="text-text-2 font-semibold">{oilPayload.weather.windDir}</span>
{' / '} <span className="text-text-2 font-semibold">{oilPayload.weather.waveHeight}</span>
{' / '} <span className="text-text-2 font-semibold">{oilPayload.weather.temp}</span>
</p>
)}
</>
)}
{/* ── HNS 대기확산 섹션들 ── */}

파일 보기

@ -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 모드
```

파일 보기

@ -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 의존성 설치
# ------------------------------------------------------------------------------

파일 보기

@ -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"]

파일 보기

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

파일 보기

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

파일 보기

@ -9,7 +9,6 @@ services:
container_name: wing-image-analysis
ports:
- "5001:5001"
env_file: .env
volumes:
# ── mx15hdi (EO 드론 카메라) ────────────────────────────────────────

파일 보기

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

파일 보기

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