wing-ops/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx
jeonghyo.k 3946ff6a25 feat(prediction): 이미지 분석 서버 Docker 패키징 + DB 코드 제거
- 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 타입 오류 수정
2026-03-10 18:37:36 +09:00

246 lines
9.9 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}