import { useState, useRef, useEffect, useCallback } from 'react'; import * as exifr from 'exifr'; import { stitchImages } from '../services/aerialApi'; import { analyzeImage } from '@tabs/prediction/services/predictionApi'; import { setPendingImageAnalysis } from '@common/utils/imageAnalysisSignal'; import { navigateToTab } from '@common/hooks/useSubMenu'; import { decimalToDMS } from '@common/utils/coordinates'; const MAX_IMAGES = 6; interface ImageExif { lat: number | null; lon: number | null; altitude: number | null; make: string | null; model: string | null; dateTime: Date | string | null; exposureTime: number | null; fNumber: number | null; iso: number | null; focalLength: number | null; imageWidth: number | null; imageHeight: number | null; } function formatFileSize(bytes?: number): string | null { if (bytes == null) return null; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function formatDateTime(dt: Date | string | null): string | null { if (!dt) return null; if (dt instanceof Date) { return dt.toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); } return String(dt); } interface MetaRowProps { label: string; value: string | null | undefined; } function MetaRow({ label, value }: MetaRowProps) { if (value == null) return null; return (
{label} {value}
); } export function OilAreaAnalysis() { const [selectedFiles, setSelectedFiles] = useState([]); const [previewUrls, setPreviewUrls] = useState([]); const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]); const [selectedImageIndex, setSelectedImageIndex] = useState(null); const [stitchedBlob, setStitchedBlob] = useState(null); const [stitchedPreviewUrl, setStitchedPreviewUrl] = useState(null); const [isStitching, setIsStitching] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false); const [error, setError] = useState(null); const fileInputRef = useRef(null); const processedFilesRef = useRef>(new Set()); // Object URL 메모리 누수 방지 — 언마운트 시 전체 revoke useEffect(() => { return () => { previewUrls.forEach(url => URL.revokeObjectURL(url)); if (stitchedPreviewUrl) URL.revokeObjectURL(stitchedPreviewUrl); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 선택된 파일이 바뀔 때마다 새 파일의 EXIF 전체 추출 useEffect(() => { selectedFiles.forEach((file, i) => { if (processedFilesRef.current.has(file)) return; processedFilesRef.current.add(file); exifr.parse(file, { gps: true, exif: true, ifd0: true, translateValues: false }) .then(exif => { const info: ImageExif = { lat: exif?.latitude ?? null, lon: exif?.longitude ?? null, altitude: exif?.GPSAltitude ?? null, make: exif?.Make ?? null, model: exif?.Model ?? null, dateTime: exif?.DateTimeOriginal ?? null, exposureTime: exif?.ExposureTime ?? null, fNumber: exif?.FNumber ?? null, iso: exif?.ISO ?? null, focalLength: exif?.FocalLength ?? null, imageWidth: exif?.ImageWidth ?? exif?.ExifImageWidth ?? null, imageHeight: exif?.ImageHeight ?? exif?.ExifImageHeight ?? null, }; setImageExifs(prev => { const updated = [...prev]; while (updated.length <= i) updated.push(undefined); updated[i] = info; return updated; }); }) .catch(() => { setImageExifs(prev => { const updated = [...prev]; while (updated.length <= i) updated.push(undefined); updated[i] = { lat: null, lon: null, altitude: null, make: null, model: null, dateTime: null, exposureTime: null, fNumber: null, iso: null, focalLength: null, imageWidth: null, imageHeight: null, }; return updated; }); }); }); }, [selectedFiles]); const handleFileSelect = useCallback((e: React.ChangeEvent) => { 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); }); setImageExifs(prev => prev.filter((_, i) => i !== idx)); setSelectedImageIndex(prev => { if (prev === null) return null; if (prev === idx) return null; if (prev > idx) return prev - 1; return prev; }); // 합성 결과 초기화 (선택 파일이 바뀌었으므로) 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 (
{/* ── Left Panel ── */}
🧩 영상사진합성
드론 사진을 합성하여 유출유 확산 면적과 기름 양을 산정합니다.
{/* 이미지 선택 버튼 */} {/* 선택된 이미지 목록 */} {selectedFiles.length > 0 && ( <>
선택된 이미지
{selectedFiles.map((file, i) => (
setSelectedImageIndex(i)} > 📷 {file.name}
{selectedImageIndex === i && imageExifs[i] !== undefined && (
)}
))}
)} {/* 에러 메시지 */} {error && (
{error}
)} {/* 이미지 합성 버튼 */} {/* 분석 시작 버튼 */}
{/* ── Right Panel ── */}
{/* 3×2 이미지 그리드 */}
선택된 이미지 미리보기
{Array.from({ length: MAX_IMAGES }).map((_, i) => (
{ if (previewUrls[i]) setSelectedImageIndex(i); }} > {previewUrls[i] ? ( <>
{selectedFiles[i]?.name
{selectedFiles[i]?.name}
{imageExifs[i] === undefined ? (
GPS 읽는 중...
) : imageExifs[i]?.lat !== null ? (
{decimalToDMS(imageExifs[i]!.lat!, true)}
{decimalToDMS(imageExifs[i]!.lon!, false)}
) : (
GPS 정보 없음
)}
) : (
{i + 1}
)}
))}
{/* 합성 결과 */}
합성 결과
{stitchedPreviewUrl ? ( 합성 결과 ) : (
{isStitching ? '⏳ 이미지를 합성하고 있습니다...' : '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
)}
); }