426 lines
18 KiB
TypeScript
426 lines
18 KiB
TypeScript
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 (
|
||
<div className="flex justify-between gap-2 py-0.5 border-b border-stroke/40 last:border-0 font-korean">
|
||
<span className="text-fg-disabled shrink-0">{label}</span>
|
||
<span className="text-fg text-right break-all">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function OilAreaAnalysis() {
|
||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||
const [previewUrls, setPreviewUrls] = useState<string[]>([]);
|
||
const [imageExifs, setImageExifs] = useState<(ImageExif | undefined)[]>([]);
|
||
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null);
|
||
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);
|
||
const processedFilesRef = useRef<Set<File>>(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<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);
|
||
});
|
||
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 (
|
||
<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-fg-disabled 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-stroke rounded-sm text-xs font-korean text-fg-sub
|
||
hover:border-color-accent hover:text-color-accent 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}`}>
|
||
<div
|
||
className={`flex items-center gap-2 px-2 py-1.5 bg-bg-card border rounded-sm text-[11px] font-korean cursor-pointer transition-colors
|
||
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||
onClick={() => setSelectedImageIndex(i)}
|
||
>
|
||
<span className="text-color-accent">📷</span>
|
||
<span className="flex-1 truncate text-fg">{file.name}</span>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); handleRemoveFile(i); }}
|
||
disabled={isStitching || isAnalyzing}
|
||
className="text-fg-disabled hover:text-color-danger transition-colors cursor-pointer
|
||
disabled:opacity-40 disabled:cursor-not-allowed ml-1 shrink-0"
|
||
title="제거"
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
{selectedImageIndex === i && imageExifs[i] !== undefined && (
|
||
<div className="mt-1 mb-1 px-2 py-1.5 bg-bg-base border border-stroke/60 rounded-sm text-[11px] font-korean">
|
||
<MetaRow label="파일 크기" value={formatFileSize(file.size)} />
|
||
<MetaRow
|
||
label="해상도"
|
||
value={imageExifs[i]!.imageWidth && imageExifs[i]!.imageHeight
|
||
? `${imageExifs[i]!.imageWidth} × ${imageExifs[i]!.imageHeight}`
|
||
: null}
|
||
/>
|
||
<MetaRow label="촬영일시" value={formatDateTime(imageExifs[i]!.dateTime)} />
|
||
<MetaRow
|
||
label="장비"
|
||
value={imageExifs[i]!.make || imageExifs[i]!.model
|
||
? [imageExifs[i]!.make, imageExifs[i]!.model].filter(Boolean).join(' ')
|
||
: null}
|
||
/>
|
||
<MetaRow
|
||
label="위도"
|
||
value={imageExifs[i]!.lat !== null ? decimalToDMS(imageExifs[i]!.lat!, true) : null}
|
||
/>
|
||
<MetaRow
|
||
label="경도"
|
||
value={imageExifs[i]!.lon !== null ? decimalToDMS(imageExifs[i]!.lon!, false) : null}
|
||
/>
|
||
<MetaRow
|
||
label="고도"
|
||
value={imageExifs[i]!.altitude !== null ? `${imageExifs[i]!.altitude!.toFixed(1)} m` : null}
|
||
/>
|
||
<MetaRow
|
||
label="셔터속도"
|
||
value={imageExifs[i]!.exposureTime
|
||
? imageExifs[i]!.exposureTime! < 1
|
||
? `1/${Math.round(1 / imageExifs[i]!.exposureTime!)}s`
|
||
: `${imageExifs[i]!.exposureTime}s`
|
||
: null}
|
||
/>
|
||
<MetaRow
|
||
label="조리개"
|
||
value={imageExifs[i]!.fNumber ? `f/${imageExifs[i]!.fNumber}` : null}
|
||
/>
|
||
<MetaRow
|
||
label="ISO"
|
||
value={imageExifs[i]!.iso ? String(imageExifs[i]!.iso) : null}
|
||
/>
|
||
<MetaRow
|
||
label="초점거리"
|
||
value={imageExifs[i]!.focalLength ? `${imageExifs[i]!.focalLength} mm` : null}
|
||
/>
|
||
</div>
|
||
)}
|
||
</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-color-danger 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-color-accent text-color-accent 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(--color-accent), var(--color-info))' } : { 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-card border rounded-sm overflow-hidden flex flex-col transition-colors
|
||
${previewUrls[i] ? 'cursor-pointer' : ''}
|
||
${selectedImageIndex === i ? 'border-color-accent' : 'border-stroke'}`}
|
||
style={{ height: '300px' }}
|
||
onClick={() => { if (previewUrls[i]) setSelectedImageIndex(i); }}
|
||
>
|
||
{previewUrls[i] ? (
|
||
<>
|
||
<div className="flex-1 min-h-0 overflow-hidden">
|
||
<img
|
||
src={previewUrls[i]}
|
||
alt={selectedFiles[i]?.name ?? ''}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
<div className="px-2 py-1 bg-bg-base border-t border-stroke shrink-0 flex items-start justify-between gap-1">
|
||
<div className="text-[10px] text-fg-sub truncate font-korean flex-1 min-w-0">
|
||
{selectedFiles[i]?.name}
|
||
</div>
|
||
{imageExifs[i] === undefined ? (
|
||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS 읽는 중...</div>
|
||
) : imageExifs[i]?.lat !== null ? (
|
||
<div className="text-[10px] text-color-accent font-mono leading-tight text-right shrink-0">
|
||
{decimalToDMS(imageExifs[i]!.lat!, true)}<br />
|
||
{decimalToDMS(imageExifs[i]!.lon!, false)}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-fg-disabled font-korean shrink-0">GPS 정보 없음</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full text-fg-disabled 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-base border border-stroke 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-fg-disabled font-korean text-center px-4">
|
||
{isStitching
|
||
? '⏳ 이미지를 합성하고 있습니다...'
|
||
: '이미지를 선택하고 합성 버튼을 클릭하면\n합성 결과가 여기에 표시됩니다.'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|