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> { 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), signal: AbortSignal.timeout(30_000),
}); });
if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`); if (!res.ok) throw new Error(`이미지 서버 응답: ${res.status}`);
@ -364,8 +364,7 @@ export async function updateSatRequestStatus(sn: number, sttsCd: string): Promis
// OIL INFERENCE (GPU 서버 프록시) // OIL INFERENCE (GPU 서버 프록시)
// ============================================================ // ============================================================
const OIL_INFERENCE_URL = process.env.OIL_INFERENCE_URL || 'http://localhost:5001'; const IMAGE_API_URL = process.env.IMAGE_API_URL ?? 'http://localhost:5001';
const IMAGE_ANALYSIS_URL = process.env.IMAGE_ANALYSIS_URL || OIL_INFERENCE_URL;
const INFERENCE_TIMEOUT_MS = 10_000; const INFERENCE_TIMEOUT_MS = 10_000;
export interface OilInferenceRegion { export interface OilInferenceRegion {
@ -393,7 +392,7 @@ export async function stitchImages(
for (const f of files) { for (const f of files) {
form.append('files', new Blob([f.buffer], { type: f.mimetype }), f.originalname); 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', method: 'POST',
body: form, body: form,
signal: AbortSignal.timeout(300_000), signal: AbortSignal.timeout(300_000),
@ -409,9 +408,8 @@ export async function stitchImages(
export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> { export async function requestOilInference(imageBase64: string): Promise<OilInferenceResult> {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS); const timeout = setTimeout(() => controller.abort(), INFERENCE_TIMEOUT_MS);
try { try {
const response = await fetch(`${OIL_INFERENCE_URL}/inference`, { const response = await fetch(`${IMAGE_API_URL}/inference`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageBase64 }), body: JSON.stringify({ image: imageBase64 }),
@ -432,7 +430,7 @@ export async function requestOilInference(imageBase64: string): Promise<OilInfer
/** GPU 추론 서버 헬스체크 */ /** GPU 추론 서버 헬스체크 */
export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> { export async function checkInferenceHealth(): Promise<{ status: string; device?: string }> {
try { try {
const response = await fetch(`${OIL_INFERENCE_URL}/health`, { const response = await fetch(`${IMAGE_API_URL}/health`, {
signal: AbortSignal.timeout(3000), signal: AbortSignal.timeout(3000),
}); });
if (!response.ok) throw new Error(`status ${response.status}`); 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')) { if (res.status === 400 && text.includes('GPS')) {
throw Object.assign(new Error('GPS_NOT_FOUND'), { code: 'GPS_NOT_FOUND' }); 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; serverResponse = await res.json() as ImageServerResponse;

파일 보기

@ -161,7 +161,7 @@ interface MapViewProps {
incidentCoord?: { lon: number; lat: number } incidentCoord?: { lon: number; lat: number }
isSelectingLocation?: boolean isSelectingLocation?: boolean
onMapClick?: (lon: number, lat: number) => void 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> selectedModels?: Set<PredictionModel>
dispersionResult?: DispersionResult | null dispersionResult?: DispersionResult | null
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }> dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>

파일 보기

@ -175,4 +175,50 @@ export function consumeHnsReportPayload(): HnsReportPayload | null {
return v; 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 } export { subMenuState }

파일 보기

@ -8,12 +8,12 @@ import { BoomDeploymentTheoryView } from './BoomDeploymentTheoryView'
import { BacktrackModal } from './BacktrackModal' import { BacktrackModal } from './BacktrackModal'
import { RecalcModal } from './RecalcModal' import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' 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 { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory } from '../services/predictionApi' 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 { useSimulationStatus } from '../hooks/useSimulationStatus'
import SimulationLoadingOverlay from './SimulationLoadingOverlay' import SimulationLoadingOverlay from './SimulationLoadingOverlay'
import { api } from '@common/services/api' import { api } from '@common/services/api'
@ -109,7 +109,7 @@ export function OilSpillView() {
const flyToTarget = null const flyToTarget = null
const fitBoundsTarget = null const fitBoundsTarget = null
const [isSelectingLocation, setIsSelectingLocation] = useState(false) 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 [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
const [windData, setWindData] = useState<WindPoint[][]>([]) const [windData, setWindData] = useState<WindPoint[][]>([])
const [hydrData, setHydrData] = useState<(HydrDataStep | null)[]>([]) 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 ( return (
<div className="relative flex flex-1 overflow-hidden"> <div className="relative flex flex-1 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar */}
@ -876,7 +932,7 @@ export function OilSpillView() {
</div> </div>
{/* Right Panel */} {/* 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 && ( {isRunningSimulation && (

파일 보기

@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { import {
createEmptyReport, createEmptyReport,
} from './OilSpillReportTemplate'; } 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 { saveReport } from '../services/reportsApi';
import { import {
CATEGORIES, CATEGORIES,
@ -32,6 +32,8 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// HNS 실 데이터 (없으면 sampleHnsData fallback) // HNS 실 데이터 (없으면 sampleHnsData fallback)
const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null) const [hnsPayload, setHnsPayload] = useState<HnsReportPayload | null>(null)
// OIL 실 데이터 (없으면 sampleOilData fallback)
const [oilPayload, setOilPayload] = useState<OilReportPayload | null>(null)
// 외부에서 카테고리 힌트가 변경되면 반영 // 외부에서 카테고리 힌트가 변경되면 반영
useEffect(() => { useEffect(() => {
@ -44,6 +46,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
// HNS 데이터 소비 // HNS 데이터 소비
const payload = consumeHnsReportPayload() const payload = consumeHnsReportPayload()
if (payload) setHnsPayload(payload) if (payload) setHnsPayload(payload)
// OIL 예측 데이터 소비
const oilData = consumeOilReportPayload()
if (oilData) setOilPayload(oilData)
}, []) }, [])
const cat = CATEGORIES[activeCat] const cat = CATEGORIES[activeCat]
@ -65,8 +70,19 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
report.status = '완료' report.status = '완료'
report.author = '시스템 자동생성' report.author = '시스템 자동생성'
if (activeCat === 0) { if (activeCat === 0) {
report.incident.pollutant = sampleOilData.pollution.oilType if (oilPayload) {
report.incident.spillAmount = sampleOilData.pollution.spillAmount 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 { try {
await saveReport(report) await saveReport(report)
@ -82,6 +98,24 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
const sectionHTML = activeSections.map(sec => { const sectionHTML = activeSections.map(sec => {
let content = `<p style="font-size:12px;color:#666;">${sec.desc}</p>`; 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 섹션에 실 데이터 삽입 // HNS 섹션에 실 데이터 삽입
if (activeCat === 1 && hnsPayload) { if (activeCat === 1 && hnsPayload) {
if (sec.id === 'hns-atm') { if (sec.id === 'hns-atm') {
@ -261,9 +295,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{[ {[
{ label: 'KOSPS', value: sampleOilData.spread.kosps, color: '#06b6d4' }, { label: 'KOSPS', value: oilPayload?.spread.kosps || sampleOilData.spread.kosps, color: '#06b6d4' },
{ label: 'OpenDrift', value: sampleOilData.spread.openDrift, color: '#ef4444' }, { label: 'OpenDrift', value: oilPayload?.spread.openDrift || sampleOilData.spread.openDrift, color: '#ef4444' },
{ label: 'POSEIDON', value: sampleOilData.spread.poseidon, color: '#f97316' }, { label: 'POSEIDON', value: oilPayload?.spread.poseidon || sampleOilData.spread.poseidon, color: '#f97316' },
].map((m, i) => ( ].map((m, i) => (
<div key={i} className="bg-bg-1 border border-border rounded-lg p-4 text-center"> <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> <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' && ( {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> {oilPayload && !oilPayload.hasSimulation && (
<tbody> <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' }}>
{[ .
['유출량', sampleOilData.pollution.spillAmount, '풍화량', sampleOilData.pollution.weathered], </div>
['해상잔유량', sampleOilData.pollution.seaRemain, '오염해역면적', sampleOilData.pollution.pollutionArea], )}
['연안부착량', sampleOilData.pollution.coastAttach, '오염해안길이', sampleOilData.pollution.coastLength], <table className="w-full table-fixed border-collapse">
].map((row, i) => ( <colgroup><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /><col style={{ width: '25%' }} /></colgroup>
<tr key={i} className="border-b border-border"> <tbody>
<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> ['유출량', oilPayload?.pollution.spillAmount || sampleOilData.pollution.spillAmount, '풍화량', oilPayload?.pollution.weathered || sampleOilData.pollution.weathered],
<td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[2]}</td> ['해상잔유량', oilPayload?.pollution.seaRemain || sampleOilData.pollution.seaRemain, '오염해역면적', oilPayload?.pollution.pollutionArea || sampleOilData.pollution.pollutionArea],
<td className="px-4 py-3 text-[12px] text-text-1 font-mono font-semibold text-right">{row[3]}</td> ['연안부착량', oilPayload?.pollution.coastAttach || sampleOilData.pollution.coastAttach, '오염해안길이', oilPayload?.pollution.coastLength || sampleOilData.pollution.coastLength],
</tr> ].map((row, i) => (
))} <tr key={i} className="border-b border-border">
</tbody> <td className="px-4 py-3 text-[11px] text-text-3 font-korean bg-[rgba(255,255,255,0.02)]">{row[0]}</td>
</table> <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' && ( {sec.id === 'oil-sensitive' && (
<> <>
@ -304,9 +345,9 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
)} )}
{sec.id === 'oil-coastal' && ( {sec.id === 'oil-coastal' && (
<p className="text-[12px] text-text-2 font-korean"> <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> </p>
)} )}
{sec.id === 'oil-defense' && ( {sec.id === 'oil-defense' && (
@ -318,11 +359,20 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) {
</div> </div>
)} )}
{sec.id === 'oil-tide' && ( {sec.id === 'oil-tide' && (
<p className="text-[12px] text-text-2 font-korean"> <>
: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span> <p className="text-[12px] text-text-2 font-korean">
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span> : <span className="font-semibold text-text-1">{sampleOilData.tide.highTide1}</span>
{' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.highTide2}</span> {' / '}: <span className="font-semibold text-text-1">{sampleOilData.tide.lowTide}</span>
</p> {' / '}: <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 대기확산 섹션들 ── */} {/* ── HNS 대기확산 섹션들 ── */}

파일 보기

@ -16,6 +16,7 @@
8. [로그 확인 및 디버깅](#8-로그-확인-및-디버깅) 8. [로그 확인 및 디버깅](#8-로그-확인-및-디버깅)
9. [컨테이너 관리](#9-컨테이너-관리) 9. [컨테이너 관리](#9-컨테이너-관리)
10. [주의사항](#10-주의사항) 10. [주의사항](#10-주의사항)
11. [CPU 전용 환경 실행](#11-cpu-전용-환경-실행)
--- ---
@ -278,10 +279,10 @@ docker system prune -f
## 10. 주의사항 ## 10. 주의사항
### GPU 필수 ### GPU 자동 감지
- AI 모델(`epoch_165.pth`)은 `cuda:0` 디바이스로 로드된다. - 서버 기동 시 `torch.cuda.is_available()`로 GPU 유무를 자동 감지한다.
- GPU 없이 실행하면 서버 기동 시 오류가 발생한다. - GPU가 있으면 `cuda:0`, 없으면 `cpu`로 자동 폴백된다.
- CPU 전용 환경에서 테스트하려면 `Inference.py``device='cuda:0'``device='cpu'`로 수정해야 한다. - 환경변수 `DEVICE`로 device를 명시 지정할 수 있다 (예: `DEVICE=cpu`, `DEVICE=cuda:1`).
### 첫 기동 시간 ### 첫 기동 시간
- AI 모델 로드: 약 **10~30초** 소요 (GPU 메모리에 로딩) - AI 모델 로드: 약 **10~30초** 소요 (GPU 메모리에 로딩)
@ -297,3 +298,79 @@ docker system prune -f
ports: ports:
- "5002:5001" # 호스트 5002 → 컨테이너 5001 - "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 서버 # 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 추론) # GPU: NVIDIA GPU 필수 (MMSegmentation 추론)
# Port: 5001 # 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 \ ENV DEBIAN_FRONTEND=noninteractive \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
@ -32,6 +33,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# rasterio는 GDAL 헤더 버전을 맞춰 빌드해야 한다 # rasterio는 GDAL 헤더 버전을 맞춰 빌드해야 한다
ENV GDAL_VERSION=3.4.1 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 의존성 설치 # 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: except subprocess.TimeoutExpired:
raise HTTPException(status_code=500, detail="Script execution timed out") raise HTTPException(status_code=500, detail="Script execution timed out")
except Exception as e: except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e)) 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 container_name: wing-image-analysis
ports: ports:
- "5001:5001" - "5001:5001"
env_file: .env
volumes: volumes:
# ── mx15hdi (EO 드론 카메라) ──────────────────────────────────────── # ── mx15hdi (EO 드론 카메라) ────────────────────────────────────────

파일 보기

@ -1,5 +1,6 @@
import os, mmcv, cv2, json import os, mmcv, cv2, json
import numpy as np import numpy as np
import torch
from pathlib import Path from pathlib import Path
from PIL import Image from PIL import Image
from tqdm import tqdm from tqdm import tqdm
@ -13,9 +14,19 @@ _MX15HDI_DIR = _DETECT_DIR.parent # mx15hdi/
def load_model(): def load_model():
"""서버 시작 시 1회 호출. 로드된 모델 객체를 반환한다.""" """서버 시작 시 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') config = str(_DETECT_DIR / 'V7_SPECIAL.py')
checkpoint = str(_DETECT_DIR / 'epoch_165.pth') checkpoint = str(_DETECT_DIR / 'epoch_165.pth')
model = init_segmentor(config, checkpoint, device='cuda:0') model = init_segmentor(config, checkpoint, device=device)
model.PALETTE = [ model.PALETTE = [
[0, 0, 0], # background [0, 0, 0], # background
[0, 0, 204], # black [0, 0, 204], # black

파일 보기

@ -4,18 +4,26 @@ uvicorn[standard]==0.29.0
# 이미지 처리 # 이미지 처리
numpy==1.26.4 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 Pillow==10.3.0
piexif==1.1.3 piexif==1.1.3
scikit-image==0.19.3
matplotlib==3.5.1
# 지리 데이터 처리 # 지리 데이터 처리
rasterio==1.3.10 rasterio==1.3.10
geopandas==0.14.4 geopandas==0.14.4
shapely==2.0.4 shapely==2.0.4
pyproj==3.6.1 pyproj==3.6.1
# osgeo(GDAL Python 바인딩)는 시스템 GDAL 버전과 맞춰야 하므로 Dockerfile에서 설치
# AI/ML — PyTorch는 base 이미지에 포함, mmsegmentation은 로컬 소스에서 설치 # AI/ML — PyTorch는 base 이미지에 포함, mmcv/mmsegmentation은 Dockerfile에서 설치
mmcv==2.1.0 # 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 pandas==2.2.2