feat(prediction): CPU 전용 Docker 환경 구축 + 이미지 분석/보고서/항공 UI 개선
This commit is contained in:
부모
4300191000
커밋
54d3a281c6
@ -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)} 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 (
|
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 의존성 설치
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|||||||
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:
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user