- 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 타입 오류 수정
246 lines
9.9 KiB
TypeScript
246 lines
9.9 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { stitchImages } from '../services/aerialApi';
|
||
import { analyzeImage } from '@tabs/prediction/services/predictionApi';
|
||
import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal';
|
||
import { navigateToTab } from '@common/hooks/useSubMenu';
|
||
|
||
const MAX_IMAGES = 6;
|
||
|
||
export function OilAreaAnalysis() {
|
||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||
const [stitchedBlob, setStitchedBlob] = useState<Blob | null>(null);
|
||
const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState<string | null>(null);
|
||
const [isStitching, setIsStitching] = useState(false);
|
||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke
|
||
useEffect(() => {
|
||
return () => {
|
||
previewUrls.forEach(url => URL.revokeObjectURL(url));
|
||
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setError(null);
|
||
const incoming = Array.from(e.target.files ?? []);
|
||
if (incoming.length === 0) return;
|
||
|
||
setSelectedFiles(prev => {
|
||
const merged = [...prev, ...incoming].slice(0, MAX_IMAGES);
|
||
if (prev.length + incoming.length > MAX_IMAGES) {
|
||
setError(`최대 ${MAX_IMAGES}장까지 선택할 수 있습니다.`);
|
||
}
|
||
return merged;
|
||
});
|
||
|
||
// setSelectedFiles updater 밖에서 독립 호출 — updater 내부 side effect는
|
||
// React Strict Mode의 이중 호출로 인해 URL이 중복 생성되는 버그를 유발함
|
||
setPreviewUrls(prev => {
|
||
const available = MAX_IMAGES - prev.length;
|
||
const toAdd = incoming.slice(0, available);
|
||
return [...prev, ...toAdd.map(f => URL.createObjectURL(f))];
|
||
});
|
||
|
||
// input 초기화 (동일 파일 재선택 허용)
|
||
e.target.value = '';
|
||
}, []);
|
||
|
||
const handleRemoveFile = useCallback((idx: number) => {
|
||
setSelectedFiles(prev => prev.filter((_, i) => i !== idx));
|
||
setPreviewUrls(prev => {
|
||
URL.revokeObjectURL(prev[idx]);
|
||
return prev.filter((_, i) => i !== idx);
|
||
});
|
||
// 합성 결과 초기화 (선택 파일이 바뀌었으므로)
|
||
setStitchedBlob(null);
|
||
if (stitchedPreviewUrl) {
|
||
URL.revokeObjectURL(stitchedPreviewUrl);
|
||
setStitchedPreviewUrl(null);
|
||
}
|
||
setError(null);
|
||
}, [stitchedPreviewUrl]);
|
||
|
||
const handleStitch = async () => {
|
||
if (selectedFiles.length < 2) {
|
||
setError('이미지를 2장 이상 선택해주세요.');
|
||
return;
|
||
}
|
||
setError(null);
|
||
setIsStitching(true);
|
||
try {
|
||
const blob = await stitchImages(selectedFiles);
|
||
if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl);
|
||
setStitchedBlob(blob);
|
||
setStitchedPreviewUrl(URL.createObjectURL(blob));
|
||
} catch (err) {
|
||
const msg =
|
||
err instanceof Error
|
||
? err.message
|
||
: (err as { message?: string }).message ?? '이미지 합성에 실패했습니다.';
|
||
const status = err instanceof Error ? 0 : (err as { status?: number }).status ?? 0;
|
||
setError(status === 504 ? '이미지 합성 서버 응답 시간이 초과되었습니다.' : msg);
|
||
} finally {
|
||
setIsStitching(false);
|
||
}
|
||
};
|
||
|
||
const handleAnalyze = async () => {
|
||
if (!stitchedBlob) return;
|
||
setError(null);
|
||
setIsAnalyzing(true);
|
||
try {
|
||
const stitchedFile = new File([stitchedBlob], `stitch_${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||
const result = await analyzeImage(stitchedFile);
|
||
setPendingImageAnalysis({ ...result, autoRun: true });
|
||
navigateToTab('prediction', 'analysis');
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : '분석에 실패했습니다.';
|
||
setError(msg.includes('GPS') ? '이미지에 GPS 정보가 없습니다. GPS 정보가 포함된 이미지를 사용해주세요.' : msg);
|
||
setIsAnalyzing(false);
|
||
}
|
||
};
|
||
|
||
const canStitch = selectedFiles.length >= 2 && !isStitching && !isAnalyzing;
|
||
const canAnalyze = stitchedBlob !== null && !isStitching && !isAnalyzing;
|
||
|
||
return (
|
||
<div className="flex gap-5 h-full overflow-hidden">
|
||
{/* ── Left Panel ── */}
|
||
<div className="w-[280px] min-w-[280px] flex flex-col overflow-y-auto scrollbar-thin">
|
||
<div className="text-sm font-bold mb-1 font-korean">🧩 유출유면적분석</div>
|
||
<div className="text-[11px] text-text-3 mb-4 font-korean">
|
||
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
|
||
</div>
|
||
|
||
{/* 이미지 선택 버튼 */}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
<button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={selectedFiles.length >= MAX_IMAGES || isStitching || isAnalyzing}
|
||
className="w-full py-2 mb-3 border border-dashed border-border rounded-sm text-xs font-korean text-text-2
|
||
hover:border-primary-cyan hover:text-primary-cyan transition-colors cursor-pointer
|
||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
+ 이미지 선택 ({selectedFiles.length}/{MAX_IMAGES})
|
||
</button>
|
||
|
||
{/* 선택된 이미지 목록 */}
|
||
{selectedFiles.length > 0 && (
|
||
<>
|
||
<div className="text-[11px] font-bold mb-1.5 font-korean">선택된 이미지</div>
|
||
<div className="flex flex-col gap-1 mb-3">
|
||
{selectedFiles.map((file, i) => (
|
||
<div
|
||
key={`${file.name}-${i}`}
|
||
className="flex items-center gap-2 px-2 py-1.5 bg-bg-3 border border-border rounded-sm text-[11px] font-korean"
|
||
>
|
||
<span className="text-primary-cyan">📷</span>
|
||
<span className="flex-1 truncate text-text-1">{file.name}</span>
|
||
<button
|
||
onClick={() => handleRemoveFile(i)}
|
||
disabled={isStitching || isAnalyzing}
|
||
className="text-text-3 hover:text-status-red transition-colors cursor-pointer
|
||
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
||
title="제거"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 에러 메시지 */}
|
||
{error && (
|
||
<div className="mb-3 px-2.5 py-2 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.3)] rounded-sm text-[11px] text-status-red font-korean">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 이미지 합성 버튼 */}
|
||
<button
|
||
onClick={handleStitch}
|
||
disabled={!canStitch}
|
||
className="w-full py-2.5 mb-2 rounded-sm text-[12px] font-bold font-korean cursor-pointer transition-colors
|
||
border border-primary-cyan text-primary-cyan bg-[rgba(6,182,212,0.06)]
|
||
hover:bg-[rgba(6,182,212,0.15)] disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{isStitching ? '⏳ 합성 중...' : stitchedBlob ? '✅ 다시 합성' : '🔗 이미지 합성'}
|
||
</button>
|
||
|
||
{/* 분석 시작 버튼 */}
|
||
<button
|
||
onClick={handleAnalyze}
|
||
disabled={!canAnalyze}
|
||
className="w-full py-3 rounded-sm text-[13px] font-bold font-korean cursor-pointer border-none transition-colors
|
||
disabled:opacity-40 disabled:cursor-not-allowed text-white"
|
||
style={canAnalyze ? { background: 'linear-gradient(135deg, var(--cyan), var(--blue))' } : { background: 'var(--bg-3)' }}
|
||
>
|
||
{isAnalyzing ? '⏳ 분석 중...' : '🧩 분석 시작'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* ── Right Panel ── */}
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{/* 3×2 이미지 그리드 */}
|
||
<div className="text-[11px] font-bold mb-2 font-korean">선택된 이미지 미리보기</div>
|
||
<div className="grid grid-cols-3 gap-1.5 mb-3">
|
||
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
|
||
<div
|
||
key={i}
|
||
className="bg-bg-3 border border-border rounded-sm overflow-hidden"
|
||
style={{ height: '300px' }}
|
||
>
|
||
{previewUrls[i] ? (
|
||
<img
|
||
src={previewUrls[i]}
|
||
alt={selectedFiles[i]?.name ?? ''}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full text-text-3 text-lg font-mono opacity-20">
|
||
{i + 1}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 합성 결과 */}
|
||
<div className="text-[11px] font-bold mb-2 font-korean">합성 결과</div>
|
||
<div
|
||
className="relative bg-bg-0 border border-border rounded-sm overflow-hidden flex items-center justify-center"
|
||
style={{ minHeight: '160px', flex: '1 1 0' }}
|
||
>
|
||
{stitchedPreviewUrl ? (
|
||
<img
|
||
src={stitchedPreviewUrl}
|
||
alt="합성 결과"
|
||
className="max-w-full max-h-full object-contain"
|
||
/>
|
||
) : (
|
||
<div className="text-[12px] text-text-3 font-korean text-center px-4">
|
||
{isStitching
|
||
? '⏳ 이미지를 합성하고 있습니다...'
|
||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|