wing-ops/frontend/src/tabs/aerial/components/OilAreaAnalysis.tsx

426 lines
18 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 * 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>
);
}