-
-
-
- {status === 'submitting' ? '서버 접수 중...' : '히트맵 생성 중...'}
-
-
+ {/* 옵션 선택 */}
+ {(status === 'idle' || status === 'error') && !isProcessing && (
+
+ {status === 'error' && (
+
+
⚠
+
{errorMsg}
+ {errorDetail &&
{errorDetail}
}
-
- {status === 'submitting' ? 'SUBMITTING...' : 'GENERATING HEATMAP...'}
-
-
- {activeBranch.toUpperCase()} BRANCH · LAYERCAM · heatmap_bbox
-
- {taskId && (
-
TASK: {taskId}
- )}
-
-
- )}
-
- {/* 결과 이미지 */}
- {status === 'done' && heatmapSrc && (
-
-

{
- e.target.style.display = 'none';
- setErrorMsg('이미지 로드 실패');
- setErrorDetail(`URL: ${heatmapSrc}`);
- setStatus('error');
- }}
- />
-
- )}
-
- {/* 결과 없음 */}
- {status === 'done' && !heatmapSrc && (
-
-
결과 이미지를 받지 못했습니다.
-
+ )}
+
+ {/* Branch */}
+
BRANCH LEVEL
+
+ {Object.keys(BRANCH_OPTIONS).map(branch => (
+
+ ))}
- )}
- {/* 오류 */}
- {status === 'error' && (
-
-
⚠
-
{errorMsg || '분석 실패'}
- {errorDetail && (
-
- {errorDetail}
-
- )}
-
+ {/* Model */}
+
MODEL TYPE
+
+ {MODEL_OPTIONS.map(m => (
+
+ ))}
- )}
-
-
- {/* 우: 컨트롤 패널 */}
-
+
+
+ )}
- {/* 브랜치 선택 */}
-
-
BRANCH LEVEL
-
- {[
- { key: 'low', desc: '국소 위조 흔적 포착 (Subtle Artifacts)' },
- { key: 'high', desc: '전역 의미 구조 포착 (Global Artifacts)' },
- ].map(({ key, desc }) => (
-
- ))}
+ {/* 로딩 */}
+ {isProcessing && (
+
+
+
GENERATING HEATMAP
+
프레임 위조 흔적 시각화 처리 중...
+
{(selectedBranch || tempBranch || '').toUpperCase()} BRANCH · {(selectedModel || tempModel || '').toUpperCase()} MODEL
+
{elapsed}s elapsed
-
+ )}
- {/* 파라미터 요약 */}
-
-
REQUEST PARAMETERS
- {[
- ['model_type', model_type || 'fast'],
- ...Object.entries(BRANCH_CONFIG[activeBranch]),
- ].map(([k, v]) => (
-
-
{k}
-
{String(v)}
+ {/* 결과 */}
+ {status === 'done' && heatmapSrc && (
+
+
+
+ ✓ {(selectedBranch || '').toUpperCase()} / {(selectedModel || '').toUpperCase()}
+
+

{ e.target.style.display = 'none'; setErrorMsg('이미지 로드 실패'); setErrorDetail(`URL: ${heatmapSrc}`); setStatus('error'); }}
+ />
- ))}
-
-
- {/* 분석 시작 버튼 */}
-
+ {/* 재선택 */}
+
+ {Object.keys(BRANCH_OPTIONS).flatMap(branch => MODEL_OPTIONS.map(m => ({ branch, m }))).map(({ branch, m }) => (
+
+ ))}
+
+
+ )}
- {/* task_id 표시 (디버그용) */}
- {taskId && (
-
-
TASK ID
-
{taskId}
+ {status === 'done' && !heatmapSrc && (
+
+
결과 이미지를 받지 못했습니다.
+
)}
@@ -440,16 +294,10 @@ const HeatmapPage = ({ sessionUser }) => {
);
diff --git a/client/src/pages/ImageHeatmapPage.js b/client/src/pages/ImageHeatmapPage.js
new file mode 100644
index 0000000..ab4362d
--- /dev/null
+++ b/client/src/pages/ImageHeatmapPage.js
@@ -0,0 +1,326 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import axios from 'axios';
+
+const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:8000";
+const POLL_INTERVAL = 2000;
+
+const BRANCH_OPTIONS = {
+ low: { branch_level: 'low', explainer_type: 'layercam', display_type: 'heatmap_bbox', overlay_ratio: 0.7, threshold: 0.9 },
+ high: { branch_level: 'high', explainer_type: 'eigengradcam', display_type: 'heatmap_bbox', overlay_ratio: 0.7, threshold: 0.9 },
+};
+const MODEL_OPTIONS = ['fast', 'pro'];
+
+const extractTaskId = (data) => {
+ if (typeof data === 'string') return data;
+ return data?.task_id || data?.id || null;
+};
+const toAbsoluteUrl = (path) => {
+ if (!path) return null;
+ if (path.startsWith('http') || path.startsWith('blob')) return path;
+ return `${apiUrl}${path}`;
+};
+
+const ImageHeatmapPage = ({ sessionUser }) => {
+ const { state } = useLocation();
+ const navigate = useNavigate();
+ const { image_id, image_loc, model_type, prob, label } = state || {};
+
+ const [tempBranch, setTempBranch] = useState('low');
+ const [tempModel, setTempModel] = useState(model_type || 'fast');
+ const [selectedBranch, setSelectedBranch] = useState(null);
+ const [selectedModel, setSelectedModel] = useState(null);
+ const [status, setStatus] = useState('idle');
+ const [heatmapSrc, setHeatmapSrc] = useState(null);
+ const [taskId, setTaskId] = useState(null);
+ const [errorMsg, setErrorMsg] = useState('');
+ const [errorDetail, setErrorDetail] = useState('');
+ const [elapsed, setElapsed] = useState(0);
+
+ const pollingRef = useRef(null);
+ const timerRef = useRef(null);
+ const isMounted = useRef(true);
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => { isMounted.current = false; stopPolling(); clearInterval(timerRef.current); };
+ }, []);
+
+ const stopPolling = () => {
+ if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
+ };
+
+ const handleBranchSelect = (branch, m) => {
+ setSelectedBranch(branch);
+ setSelectedModel(m);
+ setTempBranch(branch);
+ setTempModel(m);
+ requestHeatmap(branch, m);
+ };
+
+ const requestHeatmap = async (branch, m) => {
+ if (image_id == null) { setErrorMsg('image_id가 없습니다.'); setStatus('error'); return; }
+ stopPolling();
+ clearInterval(timerRef.current);
+ setElapsed(0);
+ setStatus('submitting');
+ setHeatmapSrc(null);
+ setErrorMsg('');
+ setErrorDetail('');
+ setTaskId(null);
+
+ const body = { model_type: m, ...BRANCH_OPTIONS[branch] };
+
+ try {
+ const res = await axios.post(`/explain/image/${image_id}`, body);
+ const tid = extractTaskId(res.data);
+ if (!tid) throw new Error(`task_id 추출 실패. 응답: ${JSON.stringify(res.data)}`);
+ setTaskId(tid);
+ setStatus('polling');
+ timerRef.current = setInterval(() => setElapsed(p => p + 1), 1000);
+ startPolling(tid);
+ } catch (e) {
+ if (!isMounted.current) return;
+ const detail = e.response?.data?.detail;
+ let msg;
+ if (Array.isArray(detail)) msg = detail.map(d => `[${d.loc?.join('.')}] ${d.msg} (입력값: ${JSON.stringify(d.input)})`).join(' / ');
+ else if (typeof detail === 'string') msg = detail;
+ else if (e.response?.data) msg = JSON.stringify(e.response.data);
+ else msg = e.message || '요청 실패';
+ setErrorMsg(`히트맵 생성 요청 실패 (${e.response?.status ?? ''})`);
+ setErrorDetail(msg);
+ setStatus('error');
+ }
+ };
+
+ const startPolling = (tid) => {
+ let cnt = 0;
+ pollingRef.current = setInterval(async () => {
+ if (!isMounted.current) return;
+ try {
+ const res = await axios.get(`/explain/image/result/${tid}`);
+ cnt = 0;
+ const d = res.data;
+ if (d === null || d === undefined) return;
+ let loc = null;
+ if (typeof d === 'string' && d.trim()) {
+ const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(d.trim());
+ if (!isUUID) loc = d.trim();
+ } else if (typeof d === 'object') {
+ loc = d.cam_loc ?? d.result_loc ?? d.image_loc ?? d.heatmap_loc ?? d.file_loc ?? d.result_path ?? d.path ?? d.url ?? d.output_path ?? null;
+ if (!loc) {
+ const st = (d.status || '').toUpperCase();
+ if (st === 'FAILED' || st === 'ERROR') { stopPolling(); clearInterval(timerRef.current); setErrorMsg('히트맵 생성 실패'); setErrorDetail(d.result_msg || d.message || '서버 오류'); setStatus('error'); return; }
+ return;
+ }
+ }
+ if (loc) { stopPolling(); clearInterval(timerRef.current); setHeatmapSrc(toAbsoluteUrl(loc)); setStatus('done'); }
+ } catch (e) {
+ cnt++;
+ if (cnt >= 5) { stopPolling(); clearInterval(timerRef.current); setErrorMsg('서버 연결 실패'); setErrorDetail(e.message); setStatus('error'); }
+ }
+ }, POLL_INTERVAL);
+ };
+
+ const isFake = (prob ?? 0) > 0.5;
+ const isProcessing = status === 'submitting' || status === 'polling';
+ const imageSrc = image_loc ? toAbsoluteUrl(image_loc) : null;
+
+ if (!state) return (
+
+
전송된 이미지 데이터가 없습니다.
+
+
+ );
+
+ return (
+
+
+
+
+
+
+
+ {/* 헤더 */}
+
+
+ {/* 스탯 바 */}
+
+ {[
+ { label: 'FAKE 확률', value: `${((prob ?? 0) * 100).toFixed(1)}%`, color: isFake ? '#FF4B4B' : '#39FF14', big: true },
+ { label: 'VERDICT', value: label || (isFake ? 'FAKE' : 'REAL'), color: isFake ? '#FF4B4B' : '#39FF14', big: true },
+ { label: 'MODEL', value: (selectedModel || model_type || 'fast').toUpperCase(), color: '#fff' },
+ ].map((item, i) => (
+
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+
+ {/* 메인 2열 */}
+
+
+ {/* 원본 이미지 */}
+ {imageSrc && (
+
+
+
+

{ e.target.style.display = 'none'; }}
+ />
+
+
+ )}
+
+ {/* 히트맵 패널 */}
+
+ {isProcessing &&
}
+
+
+
+
+
HEATMAP + BBOX OVERLAY
+ {selectedBranch &&
{selectedBranch.toUpperCase()} / {(selectedModel || '').toUpperCase()}}
+
+
+ {isProcessing &&
{elapsed}s}
+ {status === 'done' && heatmapSrc && (
+
+ ↓ SAVE
+
+ )}
+
+
+
+
+
+ {/* 옵션 선택 */}
+ {(status === 'idle' || status === 'error') && !isProcessing && (
+
+ {status === 'error' && (
+
+
⚠
+
{errorMsg}
+ {errorDetail &&
{errorDetail}
}
+
+ )}
+
+ {/* Branch 선택 */}
+
BRANCH LEVEL
+
+ {Object.keys(BRANCH_OPTIONS).map(branch => (
+
+ ))}
+
+
+ {/* Model 선택 */}
+
MODEL TYPE
+
+ {MODEL_OPTIONS.map(m => (
+
+ ))}
+
+
+ {/* 실행 버튼 */}
+
+
+ )}
+
+ {/* 로딩 */}
+ {isProcessing && (
+
+
+
GENERATING HEATMAP
+
위조 흔적 시각화 처리 중...
+
{(selectedBranch || tempBranch || '').toUpperCase()} BRANCH · {(selectedModel || tempModel || '').toUpperCase()} MODEL
+
{elapsed}s elapsed
+
+ )}
+
+ {/* 결과 */}
+ {status === 'done' && heatmapSrc && (
+
+
+
+ ✓ {(selectedBranch || '').toUpperCase()} / {(selectedModel || '').toUpperCase()}
+
+

{ e.target.style.display = 'none'; setErrorMsg('이미지 로드 실패'); setErrorDetail(`URL: ${heatmapSrc}`); setStatus('error'); }}
+ />
+
+ {/* 재선택 버튼 */}
+
+ {Object.keys(BRANCH_OPTIONS).flatMap(branch => MODEL_OPTIONS.map(m => ({ branch, m }))).map(({ branch, m }) => (
+
+ ))}
+
+
+ )}
+
+ {status === 'done' && !heatmapSrc && (
+
+
결과 이미지를 받지 못했습니다.
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
+
+export default ImageHeatmapPage;
\ No newline at end of file
diff --git a/client/src/pages/VideoTimelinePage.js b/client/src/pages/VideoTimelinePage.js
index 862d94f..b6daf7d 100644
--- a/client/src/pages/VideoTimelinePage.js
+++ b/client/src/pages/VideoTimelinePage.js
@@ -17,6 +17,7 @@ const VideoTimelinePage = ({ sessionUser }) => {
const { state: data } = useLocation();
const navigate = useNavigate();
const [timelineData, setTimelineData] = useState([]);
+ const [videoMeta, setVideoMeta] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const videoId = data?.video_id || data?.id;
@@ -38,6 +39,7 @@ const VideoTimelinePage = ({ sessionUser }) => {
let frames = [];
if (d?.frames?.length) frames = d.frames;
else if (d?.timeline?.length) frames = d.timeline;
+ if (d?.meta) setVideoMeta(d.meta);
if (frames.length) {
setTimelineData(frames.map(normalizeFrame));
} else {
@@ -58,6 +60,12 @@ const VideoTimelinePage = ({ sessionUser }) => {
fetchTimelineDetails();
}, [videoId]);
+ // ── fake/real 비율 (프레임 카운트 기준) ──
+ const fakeCount = timelineData.filter(f => f.fake_score > 50).length;
+ const totalCount = timelineData.length;
+ const fakeRatio = totalCount ? (fakeCount / totalCount) * 100 : 0;
+ const realRatio = 100 - fakeRatio;
+
// ── Canvas 렌더링 ──
// 핵심: 각 막대 div의 getBoundingClientRect()로 실제 화면 위치를 측정해
// 캔버스 좌표로 변환 → 완벽하게 막대 상단 중앙을 통과하는 꺾은선
@@ -188,20 +196,51 @@ const VideoTimelinePage = ({ sessionUser }) => {