release: 2026-03-11.2 (12건 커밋) #85
@ -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)} 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 (
|
||||
<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 의존성 설치
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
112
prediction/image/Dockerfile.cpu
Normal file
112
prediction/image/Dockerfile.cpu
Normal file
@ -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))
|
||||
|
||||
|
||||
|
||||
46
prediction/image/docker-compose.cpu.yml
Normal file
46
prediction/image/docker-compose.cpu.yml
Normal file
@ -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
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user