- prediction/image/ FastAPI 서버 Docker 환경 구성 - Dockerfile: PyTorch 2.1 + CUDA 12.1 기반 GPU 이미지 - docker-compose.yml: GPU 할당 + 데이터 볼륨 마운트 - requirements.txt: 서버 의존성 목록 - .env.example: 환경변수 템플릿 - DOCKER_USAGE.md: 빌드/실행/API 사용법 문서 - Dockerfile에 .dockerignore 제외 폴더 mkdir -p 추가 - .gitignore: prediction/image 결과물 및 모델 가중치(.pth) 제외 추가 - dbInsert_csv.py, dbInsert_shp.py 삭제 (미사용 DB 로직) - api.py: dbInsert import 및 주석 처리된 DB 호출 코드 제거 - aerialRouter.ts: req.params 타입 오류 수정
436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
import { useState, useRef } from 'react'
|
|
import { decimalToDMS } from '@common/utils/coordinates'
|
|
import { ComboBox } from '@common/components/ui/ComboBox'
|
|
import { ALL_MODELS } from './OilSpillView'
|
|
import type { PredictionModel } from './OilSpillView'
|
|
import { analyzeImage } from '../services/predictionApi'
|
|
import type { ImageAnalyzeResult } from '../services/predictionApi'
|
|
|
|
interface PredictionInputSectionProps {
|
|
expanded: boolean
|
|
onToggle: () => void
|
|
accidentTime: string
|
|
onAccidentTimeChange: (time: string) => void
|
|
incidentCoord: { lon: number; lat: number } | null
|
|
onCoordChange: (coord: { lon: number; lat: number }) => void
|
|
isSelectingLocation: boolean
|
|
onMapSelectClick: () => void
|
|
onRunSimulation: () => void
|
|
isRunningSimulation: boolean
|
|
selectedModels: Set<PredictionModel>
|
|
onModelsChange: (models: Set<PredictionModel>) => void
|
|
predictionTime: number
|
|
onPredictionTimeChange: (time: number) => void
|
|
spillType: string
|
|
onSpillTypeChange: (type: string) => void
|
|
oilType: string
|
|
onOilTypeChange: (type: string) => void
|
|
spillAmount: number
|
|
onSpillAmountChange: (amount: number) => void
|
|
incidentName: string
|
|
onIncidentNameChange: (name: string) => void
|
|
spillUnit: string
|
|
onSpillUnitChange: (unit: string) => void
|
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void
|
|
}
|
|
|
|
const PredictionInputSection = ({
|
|
expanded,
|
|
onToggle,
|
|
accidentTime,
|
|
onAccidentTimeChange,
|
|
incidentCoord,
|
|
onCoordChange,
|
|
isSelectingLocation,
|
|
onMapSelectClick,
|
|
onRunSimulation,
|
|
isRunningSimulation,
|
|
selectedModels,
|
|
onModelsChange,
|
|
predictionTime,
|
|
onPredictionTimeChange,
|
|
spillType,
|
|
onSpillTypeChange,
|
|
oilType,
|
|
onOilTypeChange,
|
|
spillAmount,
|
|
onSpillAmountChange,
|
|
incidentName,
|
|
onIncidentNameChange,
|
|
spillUnit,
|
|
onSpillUnitChange,
|
|
onImageAnalysisResult,
|
|
}: PredictionInputSectionProps) => {
|
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct')
|
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
|
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
|
const [analyzeError, setAnalyzeError] = useState<string | null>(null)
|
|
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null)
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0] ?? null
|
|
setUploadedFile(file)
|
|
setAnalyzeError(null)
|
|
setAnalyzeResult(null)
|
|
}
|
|
|
|
const handleRemoveFile = () => {
|
|
setUploadedFile(null)
|
|
setAnalyzeError(null)
|
|
setAnalyzeResult(null)
|
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
}
|
|
|
|
const handleAnalyze = async () => {
|
|
if (!uploadedFile) return
|
|
setIsAnalyzing(true)
|
|
setAnalyzeError(null)
|
|
try {
|
|
const result = await analyzeImage(uploadedFile)
|
|
setAnalyzeResult(result)
|
|
onImageAnalysisResult?.(result)
|
|
} catch (err: unknown) {
|
|
if (err && typeof err === 'object' && 'response' in err) {
|
|
const res = (err as { response?: { data?: { error?: string } } }).response
|
|
if (res?.data?.error === 'GPS_NOT_FOUND') {
|
|
setAnalyzeError('GPS 정보가 없는 이미지입니다')
|
|
return
|
|
}
|
|
if (res?.data?.error === 'TIMEOUT') {
|
|
setAnalyzeError('분석 서버 응답 없음 (시간 초과)')
|
|
return
|
|
}
|
|
}
|
|
setAnalyzeError('이미지 분석 중 오류가 발생했습니다')
|
|
} finally {
|
|
setIsAnalyzing(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="border-b border-border">
|
|
<div
|
|
onClick={onToggle}
|
|
className="flex items-center justify-between p-4 cursor-pointer hover:bg-[rgba(255,255,255,0.02)]"
|
|
>
|
|
<h3 className="text-[13px] font-bold text-text-2 font-korean">
|
|
예측정보 입력
|
|
</h3>
|
|
<span className="text-[10px] text-text-3">
|
|
{expanded ? '▼' : '▶'}
|
|
</span>
|
|
</div>
|
|
|
|
{expanded && (
|
|
<div className="px-4 pb-4 flex flex-col gap-[6px]">
|
|
{/* Input Mode Selection */}
|
|
<div className="flex items-center gap-[10px] text-[11px]">
|
|
<label className="flex items-center gap-[3px] cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'direct'}
|
|
onChange={() => setInputMode('direct')}
|
|
className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
|
|
/>
|
|
직접 입력
|
|
</label>
|
|
<label className="flex items-center gap-[3px] cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="prdType"
|
|
checked={inputMode === 'upload'}
|
|
onChange={() => setInputMode('upload')}
|
|
className="accent-[var(--cyan)] m-0 w-[11px] h-[11px]"
|
|
/>
|
|
이미지 업로드
|
|
</label>
|
|
</div>
|
|
|
|
{/* Direct Input Mode */}
|
|
{inputMode === 'direct' && (
|
|
<>
|
|
<input
|
|
className="prd-i"
|
|
placeholder="사고명 직접 입력"
|
|
value={incidentName}
|
|
onChange={(e) => onIncidentNameChange(e.target.value)}
|
|
/>
|
|
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
|
</>
|
|
)}
|
|
|
|
{/* Image Upload Mode */}
|
|
{inputMode === 'upload' && (
|
|
<>
|
|
{/* 파일 선택 영역 */}
|
|
{!uploadedFile ? (
|
|
<label
|
|
className="flex items-center justify-center text-[11px] text-text-3 cursor-pointer"
|
|
style={{
|
|
padding: '20px',
|
|
background: 'var(--bg0)',
|
|
border: '2px dashed var(--bd)',
|
|
borderRadius: 'var(--rS)',
|
|
transition: '0.15s',
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--cyan)'
|
|
e.currentTarget.style.background = 'rgba(6,182,212,0.05)'
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
e.currentTarget.style.borderColor = 'var(--bd)'
|
|
e.currentTarget.style.background = 'var(--bg0)'
|
|
}}
|
|
>
|
|
📁 이미지 파일을 선택하세요
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
) : (
|
|
<div
|
|
className="flex items-center justify-between font-mono text-[10px] bg-bg-0 border border-border"
|
|
style={{ padding: '8px 10px', borderRadius: 'var(--rS)' }}
|
|
>
|
|
<span className="text-text-2">📄 {uploadedFile.name}</span>
|
|
<button
|
|
onClick={handleRemoveFile}
|
|
className="text-[10px] text-text-3 bg-transparent border-none cursor-pointer"
|
|
style={{ padding: '2px 6px', transition: '0.15s' }}
|
|
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--red)' }}
|
|
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--t3)' }}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 분석 실행 버튼 */}
|
|
<button
|
|
className="prd-btn pri"
|
|
style={{ padding: '7px', fontSize: '11px' }}
|
|
onClick={handleAnalyze}
|
|
disabled={!uploadedFile || isAnalyzing}
|
|
>
|
|
{isAnalyzing ? '⏳ 분석 중...' : '🔍 이미지 분석 실행'}
|
|
</button>
|
|
|
|
{/* 에러 메시지 */}
|
|
{analyzeError && (
|
|
<div
|
|
className="text-[10px] font-semibold"
|
|
style={{
|
|
padding: '6px 8px',
|
|
background: 'rgba(239,68,68,0.1)',
|
|
border: '1px solid rgba(239,68,68,0.3)',
|
|
borderRadius: 'var(--rS)',
|
|
color: 'var(--red)',
|
|
}}
|
|
>
|
|
⚠ {analyzeError}
|
|
</div>
|
|
)}
|
|
|
|
{/* 분석 완료 메시지 */}
|
|
{analyzeResult && (
|
|
<div
|
|
className="text-[10px] font-semibold"
|
|
style={{
|
|
padding: '6px 8px',
|
|
background: 'rgba(34,197,94,0.1)',
|
|
border: '1px solid rgba(34,197,94,0.3)',
|
|
borderRadius: 'var(--rS)',
|
|
color: '#22c55e',
|
|
lineHeight: 1.6,
|
|
}}
|
|
>
|
|
✓ 분석 완료<br />
|
|
<span className="font-normal text-text-3">
|
|
위도 {analyzeResult.lat.toFixed(4)} / 경도 {analyzeResult.lon.toFixed(4)}<br />
|
|
유종: {analyzeResult.oilType} / 면적: {analyzeResult.area.toFixed(1)} m²
|
|
</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* 사고 발생 시각 */}
|
|
<div className="flex flex-col gap-0.5">
|
|
<label className="text-[9px] text-text-3 font-korean">사고 발생 시각 (KST)</label>
|
|
<input
|
|
className="prd-i"
|
|
type="datetime-local"
|
|
value={accidentTime}
|
|
onChange={(e) => onAccidentTimeChange(e.target.value)}
|
|
style={{ colorScheme: 'dark' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Coordinates + Map Button */}
|
|
<div className="flex flex-col gap-1">
|
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 1fr auto' }}>
|
|
<input
|
|
className="prd-i"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lat ?? ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
|
onCoordChange({ lon: incidentCoord?.lon ?? 0, lat: isNaN(value) ? 0 : value })
|
|
}}
|
|
placeholder="위도°"
|
|
/>
|
|
<input
|
|
className="prd-i"
|
|
type="number"
|
|
step="0.0001"
|
|
value={incidentCoord?.lon ?? ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
|
|
onCoordChange({ lat: incidentCoord?.lat ?? 0, lon: isNaN(value) ? 0 : value })
|
|
}}
|
|
placeholder="경도°"
|
|
/>
|
|
<button
|
|
className={`prd-map-btn${isSelectingLocation ? ' active' : ''}`}
|
|
onClick={onMapSelectClick}
|
|
>📍 지도</button>
|
|
</div>
|
|
{/* 도분초 표시 */}
|
|
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
|
<div
|
|
className="text-[9px] text-text-3 font-mono border border-border bg-bg-0"
|
|
style={{ padding: '4px 8px', borderRadius: 'var(--rS)' }}
|
|
>
|
|
{decimalToDMS(incidentCoord.lat, true)} / {decimalToDMS(incidentCoord.lon, false)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Oil Type + Oil Kind */}
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={spillType}
|
|
onChange={onSpillTypeChange}
|
|
options={[
|
|
{ value: '연속', label: '연속' },
|
|
{ value: '비연속', label: '비연속' },
|
|
{ value: '순간 유출', label: '순간 유출' }
|
|
]}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={oilType}
|
|
onChange={onOilTypeChange}
|
|
options={[
|
|
{ value: '벙커C유', label: '벙커C유' },
|
|
{ value: '경유', label: '경유' },
|
|
{ value: '원유', label: '원유' },
|
|
{ value: '중유', label: '중유' },
|
|
{ value: '등유', label: '등유' },
|
|
{ value: '휘발유', label: '휘발유' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* Volume + Unit + Duration */}
|
|
<div className="grid items-center gap-1" style={{ gridTemplateColumns: '1fr 65px 1fr' }}>
|
|
<input
|
|
className="prd-i"
|
|
placeholder="유출량"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={spillAmount}
|
|
onChange={(e) => onSpillAmountChange(parseInt(e.target.value) || 0)}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={spillUnit}
|
|
onChange={onSpillUnitChange}
|
|
options={[
|
|
{ value: 'kL', label: 'kL' },
|
|
{ value: 'ton', label: 'Ton' },
|
|
{ value: 'barrel', label: '배럴' }
|
|
]}
|
|
/>
|
|
<ComboBox
|
|
className="prd-i"
|
|
value={predictionTime}
|
|
onChange={(v) => onPredictionTimeChange(parseInt(v))}
|
|
options={[
|
|
{ value: '6', label: '6시간' },
|
|
{ value: '12', label: '12시간' },
|
|
{ value: '24', label: '24시간' },
|
|
{ value: '48', label: '48시간' },
|
|
{ value: '72', label: '72시간' }
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{/* Divider */}
|
|
<div className="h-px bg-border my-0.5" />
|
|
|
|
{/* Model Selection (다중 선택) */}
|
|
<div className="flex flex-wrap gap-[3px]">
|
|
{([
|
|
{ id: 'KOSPS' as PredictionModel, color: 'var(--cyan)' },
|
|
{ id: 'POSEIDON' as PredictionModel, color: 'var(--red)' },
|
|
{ id: 'OpenDrift' as PredictionModel, color: 'var(--blue)' },
|
|
] as const).map(m => (
|
|
<div
|
|
key={m.id}
|
|
className={`prd-mc ${selectedModels.has(m.id) ? 'on' : ''} cursor-pointer`}
|
|
onClick={() => {
|
|
const next = new Set(selectedModels)
|
|
if (next.has(m.id)) {
|
|
next.delete(m.id)
|
|
} else {
|
|
next.add(m.id)
|
|
}
|
|
onModelsChange(next)
|
|
}}
|
|
>
|
|
<span className="prd-md" style={{ background: m.color }} />
|
|
{m.id}
|
|
</div>
|
|
))}
|
|
<div
|
|
className={`prd-mc ${selectedModels.size === ALL_MODELS.length ? 'on' : ''} cursor-pointer`}
|
|
onClick={() => {
|
|
if (selectedModels.size === ALL_MODELS.length) {
|
|
onModelsChange(new Set(['KOSPS']))
|
|
} else {
|
|
onModelsChange(new Set(ALL_MODELS))
|
|
}
|
|
}}
|
|
>
|
|
<span className="prd-md" style={{ background: 'var(--purple)' }} />
|
|
앙상블
|
|
</div>
|
|
</div>
|
|
|
|
{/* Run Button */}
|
|
<button
|
|
className="prd-btn pri mt-0.5"
|
|
style={{ padding: '7px', fontSize: '11px' }}
|
|
onClick={onRunSimulation}
|
|
disabled={isRunningSimulation}
|
|
>
|
|
{isRunningSimulation ? '⏳ 실행 중...' : '🔬 확산예측 실행'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default PredictionInputSection
|