diff --git a/client/src/App.js b/client/src/App.js index 576a68e..72f1684 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -10,6 +10,7 @@ import AnalysisDetailPage from './pages/AnalysisDetailPage'; import VideoAnalysisPage from './pages/VideoAnalysisPage'; import VideoTimelinePage from './pages/VideoTimelinePage'; import HeatmapPage from './pages/HeatmapPage'; +import ImageHeatmapPage from './pages/ImageHeatmapPage'; axios.defaults.withCredentials = true; @@ -21,7 +22,6 @@ function App() { const checkSession = async () => { try { const response = await axios.get('/auth/check'); - if (response.data && response.data.user) { setSessionUser(response.data.user); } @@ -56,11 +56,14 @@ function App() { } /> } /> + + } /> + + } /> } /> } /> } /> - } /> ); diff --git a/client/src/pages/AnalysisDetailPage.js b/client/src/pages/AnalysisDetailPage.js index 36f0235..2c3b4ed 100644 --- a/client/src/pages/AnalysisDetailPage.js +++ b/client/src/pages/AnalysisDetailPage.js @@ -98,17 +98,17 @@ const AnalysisDetailPage = ({ sessionUser }) => { {/* 2분할 메인 그리드 레이아웃 */} -
+
{/* 왼쪽 섹션: 미디어 플레이어 전용 배치 존 */} -
+
{isVideo && mediaLoc ? (
@@ -119,16 +119,35 @@ const AnalysisDetailPage = ({ sessionUser }) => { onMouseEnter={(e) => { e.target.style.backgroundColor = 'rgba(57, 255, 20, 0.06)'; e.target.style.borderColor = '#39FF14'; e.target.style.boxShadow = '0 0 15px rgba(57, 255, 20, 0.15)'; }} onMouseLeave={(e) => { e.target.style.backgroundColor = 'transparent'; e.target.style.borderColor = 'rgba(57, 255, 20, 0.4)'; e.target.style.boxShadow = '0 2px 8px rgba(57, 255, 20, 0.05)'; }} > - EXPAND TIMELINE ANALYSIS + Forgery Localization: Heatmap + BBox + + )} + + {!isVideo && mediaLoc && !isWarning && ( + )}
{/* 오른쪽 섹션: 스코어 보드 or WARNING 메시지 */} -
+
{isWarning ? ( - // [수정] WARNING일 때 — 경고 메시지만 표시, 메트릭 숨김 + // WARNING일 때 — 경고 메시지만, 꽉 채움

UNDETECTED

@@ -138,43 +157,44 @@ const AnalysisDetailPage = ({ sessionUser }) => {
) : ( <> - {/* 종합 리포트 판정 카드 */} -
-

FINAL ANALYSIS

-

+ {/* 종합 리포트 판정 카드 — flex:1로 남은 공간 채움 */} +
+

FINAL ANALYSIS

+

{label}

-

{displayProb}

+
+

FAKE 확률

+

{displayProb}

- {/* 하단 메트릭 상세 그리드 구조 */} -
- -
-

BRIGHTNESS

-

{Number(face_brightness).toFixed(1)}%

-
-
+ {/* 보조 메트릭 — 작게 고정 높이 */} +
+

DETAIL METRICS

+
-
-

FACE RATIO

-

{Number(face_ratio).toFixed(1)}%

-
-
+ {/* BRIGHTNESS */} +
+ 얼굴 밝기 + {Number(face_brightness).toFixed(1)}% +
+ + {/* FACE RATIO */} +
+ 얼굴 비율 + {Number(face_ratio).toFixed(1)}% +
-
-

MODEL CONFIDENCE

-

{Number(face_conf).toFixed(1)}%

- -
+ {/* MODEL CONFIDENCE */} +
+ 모델 신뢰도 + {Number(face_conf).toFixed(1)}% +
+
- -

- {data.message || (isInvalid ? "분석 데이터를 불러오지 못했습니다." : "정상 분석 리포트입니다.")} -

-
+
)} diff --git a/client/src/pages/HeatmapPage.js b/client/src/pages/HeatmapPage.js index e51c082..73e3c18 100644 --- a/client/src/pages/HeatmapPage.js +++ b/client/src/pages/HeatmapPage.js @@ -5,40 +5,16 @@ import axios from 'axios'; const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:8000"; const POLL_INTERVAL = 2000; -const BRANCH_CONFIG = { - low: { - branch_level: 'low', - explainer_type: 'layercam', // low branch: LayerCAM - display_type: 'heatmap_bbox', - overlay_ratio: 0.7, - threshold: 0.9, - aug_smooth: false, - eigen_smooth: true, - }, - high: { - branch_level: 'high', - explainer_type: 'xgradcam', // high branch: XGradCAM (허용값: gradcamelementwise, layercam, xgradcam) - display_type: 'heatmap_bbox', - overlay_ratio: 0.7, - threshold: 0.9, - aug_smooth: false, - eigen_smooth: true, - }, +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']; -// API 응답에서 이미지 경로 추출 -// POST /explain/video/{id}/frame/{idx} → 202, body = "task_id_string" -// GET /explain/frame/result/{task_id} → 200, body = "image_path_string" or { result_loc, ... } const extractTaskId = (data) => { if (typeof data === 'string') return data; return data?.task_id || data?.id || null; }; - -const extractImagePath = (data) => { - if (typeof data === 'string') return data; - return data?.result_loc || data?.image_loc || data?.heatmap_loc || data?.file_loc || null; -}; - const toAbsoluteUrl = (path) => { if (!path) return null; if (path.startsWith('http') || path.startsWith('blob')) return path; @@ -47,148 +23,101 @@ const toAbsoluteUrl = (path) => { const HeatmapPage = ({ sessionUser }) => { const { state } = useLocation(); - const navigate = useNavigate(); - + const navigate = useNavigate(); const { video_id, frame_index, timestamp, fake_score, model_type, video_name } = state || {}; - const [activeBranch, setActiveBranch] = useState('high'); - // status: 'idle' | 'submitting' | 'polling' | 'done' | 'error' - const [status, setStatus] = useState('idle'); - const [heatmapSrc, setHeatmapSrc] = useState(null); - const [taskId, setTaskId] = useState(null); - const [errorMsg, setErrorMsg] = useState(''); - const [errorDetail, setErrorDetail] = useState(''); // 개발용 상세 에러 + 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 isMounted = useRef(true); + const timerRef = useRef(null); + const isMounted = useRef(true); useEffect(() => { isMounted.current = true; - return () => { - isMounted.current = false; - stopPolling(); - }; + return () => { isMounted.current = false; stopPolling(); clearInterval(timerRef.current); }; }, []); const stopPolling = () => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } }; - const handleBranchChange = (branch) => { - if (status === 'submitting' || status === 'polling') return; - stopPolling(); - setActiveBranch(branch); - setStatus('idle'); - setHeatmapSrc(null); - setTaskId(null); - setErrorMsg(''); - setErrorDetail(''); + const handleBranchSelect = (branch, m) => { + setSelectedBranch(branch); + setSelectedModel(m); + setTempBranch(branch); + setTempModel(m); + requestHeatmap(branch, m); }; - // ── STEP 1: POST 접수 → task_id 수신 ── - const requestHeatmap = async () => { - if (video_id == null) { - setErrorMsg('video_id가 없습니다.'); - setErrorDetail('state에 video_id가 전달되지 않았습니다.'); - setStatus('error'); - return; - } - + const requestHeatmap = async (branch, m) => { + if (video_id == null) { setErrorMsg('video_id가 없습니다.'); setStatus('error'); return; } stopPolling(); + clearInterval(timerRef.current); + setElapsed(0); setStatus('submitting'); setHeatmapSrc(null); setErrorMsg(''); setErrorDetail(''); setTaskId(null); - const body = { - model_type: model_type || 'fast', - ...BRANCH_CONFIG[activeBranch], - }; + const body = { model_type: m, ...BRANCH_OPTIONS[branch] }; try { - // POST /explain/video/{video_id}/frame/{frame_index} - // Response 202: "task_id_string" - const res = await axios.post( - `/explain/video/${video_id}/frame/${frame_index ?? 0}`, - body - ); - + const res = await axios.post(`/explain/video/${video_id}/frame/${frame_index ?? 0}`, body); const tid = extractTaskId(res.data); - if (!tid) { - throw new Error(`task_id 추출 실패. 응답: ${JSON.stringify(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 msg = e.response?.data?.detail || e.response?.data || e.message || '요청 실패'; - setErrorMsg('히트맵 생성 요청 실패'); - setErrorDetail(typeof msg === 'string' ? msg : JSON.stringify(msg)); + 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'); } }; - // ── STEP 2: GET 폴링 → 결과 이미지 수신 ── const startPolling = (tid) => { - let networkErrorCount = 0; - const MAX_ERRORS = 5; - + let cnt = 0; pollingRef.current = setInterval(async () => { if (!isMounted.current) return; try { const res = await axios.get(`/explain/frame/result/${tid}`); - networkErrorCount = 0; + cnt = 0; const d = res.data; - - // 디버그: 실제 응답 구조 확인 (확인 후 제거 가능) - console.log('[heatmap polling] response:', JSON.stringify(d)); - if (d === null || d === undefined) return; - let loc = null; - - if (typeof d === 'string' && d.length > 0) { - // 응답이 문자열: 이미지 경로인지 확인 - if (d.includes('/') || d.match(/\.(png|jpg|jpeg|webp)/i)) { - loc = d; - } + 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.result_loc ?? d.image_loc ?? d.heatmap_loc ?? d.file_loc - ?? d.result_path ?? d.path ?? d.url ?? d.image_url ?? d.output_path - ?? d.result ?? null; - + 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(); - setErrorMsg('히트맵 생성 실패'); - setErrorDetail(d.result_msg || d.message || d.detail || '서버 오류'); - setStatus('error'); - return; - } - // PENDING / STARTED → 계속 폴링 + if (st === 'FAILED' || st === 'ERROR') { stopPolling(); clearInterval(timerRef.current); setErrorMsg('히트맵 생성 실패'); setErrorDetail(d.result_msg || d.message || '서버 오류'); setStatus('error'); return; } return; } } - - if (loc) { - stopPolling(); - setHeatmapSrc(toAbsoluteUrl(loc)); - setStatus('done'); - } + if (loc) { stopPolling(); clearInterval(timerRef.current); setHeatmapSrc(toAbsoluteUrl(loc)); setStatus('done'); } } catch (e) { - networkErrorCount++; - console.warn(`[heatmap polling] 에러 ${networkErrorCount}/${MAX_ERRORS}:`, e.message); - if (networkErrorCount >= MAX_ERRORS) { - stopPolling(); - setErrorMsg('서버 연결 실패'); - setErrorDetail(`${MAX_ERRORS}회 연속 연결 오류. 백엔드 서버 상태를 확인해주세요. (${e.message})`); - setStatus('error'); - } + cnt++; + if (cnt >= 5) { stopPolling(); clearInterval(timerRef.current); setErrorMsg('서버 연결 실패'); setErrorDetail(e.message); setStatus('error'); } } }, POLL_INTERVAL); }; @@ -196,243 +125,168 @@ const HeatmapPage = ({ sessionUser }) => { const isFake = (fake_score ?? 0) > 50; const isProcessing = status === 'submitting' || status === 'polling'; - if (!state) { - return ( -
-

전송된 프레임 데이터가 없습니다.

- -
- ); - } + if (!state) return ( +
+

전송된 프레임 데이터가 없습니다.

+ +
+ ); return ( -
- - {/* 헤더 */} -
- -
- - FORGERY TRACE VISUALIZATION - - {sessionUser && ( -
- ANALYSIS TASK MANAGER: - {sessionUser.name} +
+
+
+
+ +
+ + {/* 헤더 */} +
+ +
+
+ {isFake ? 'FAKE' : 'REAL'}
- )} -
-
- -
- - {/* 프레임 메타 패널 */} -
-
-

TARGET METADATA

-

{video_name || `STREAM_INSTANCE_${video_id}`}

-
-
- FRAME INDEX - #{frame_index ?? 0} +
FRAME FORGERY TRACE
+ {sessionUser && ( +
+ 담당: + {sessionUser.name}
-
- TIMESTAMP - {timestamp || '—'} -
-
- FORGERY RISK - - {Number(fake_score ?? 0).toFixed(1)}% - -
-
- VERDICT - - {isFake ? 'FAKE' : 'REAL'} - -
-
+ )}
+
+ + {/* 스탯 바 */} +
+ {[ + { label: 'VIDEO', value: video_name || `ID_${video_id}`, color: '#fff' }, + { label: 'TIMESTAMP', value: timestamp || `#${frame_index}`, color: '#39FF14', big: true }, + { label: 'FRAME INDEX', value: `#${frame_index ?? 0}`, color: '#fff' }, + { label: 'FORGERY RISK', value: `${Number(fake_score ?? 0).toFixed(1)}%`, color: isFake ? '#FF4B4B' : '#39FF14', big: true }, + { label: 'VERDICT', value: isFake ? 'FAKE' : 'REAL', color: isFake ? '#FF4B4B' : '#39FF14', big: true }, + ].map((item, i) => ( +
+
+

{item.label}

+

{item.value}

+
+ ))}
- {/* 메인 2열 */} -
- - {/* 좌: 히트맵 결과 뷰어 */} -
+ {/* 히트맵 패널 */} +
+ {isProcessing &&
} - {/* 뷰어 헤더 */} -
- HEATMAP + BBOX OVERLAY +
+
+
+ HEATMAP + BBOX OVERLAY + {selectedBranch && {selectedBranch.toUpperCase()} / {(selectedModel || '').toUpperCase()}} +
+
+ {isProcessing && {elapsed}s} {status === 'done' && heatmapSrc && ( - + ↓ SAVE )}
+
- {/* 뷰어 본문 */} -
- - {/* idle */} - {status === 'idle' && ( -
-

BRANCH LEVEL을 선택하고

-

GENERATE를 실행하세요

-
- )} +
- {/* 처리중 */} - {isProcessing && ( -
-
-
-
- - {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 && ( -
- HeatMap + BBox { - 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()} +
+ Heatmap { 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 || (isFake ? 'FAKE' : 'REAL')} +
+
IMAGE FORGERY TRACE
+ {sessionUser && ( +
+ 담당: + {sessionUser.name} +
+ )} +
+
+ + {/* 스탯 바 */} +
+ {[ + { 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 && ( +
+
+
+ ORIGINAL IMAGE +
+
+ Original { 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()} +
+ Heatmap { 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 }) => {
-

TARGET METADATA

-

{data.video_name || `STREAM_INSTANCE_${videoId}`}

-
-
- ANALYSIS MODEL - {data.model_type?.toUpperCase() || 'FAST'} ENGINE -
-
- CORE KERNEL VERSION - {data.version_type?.toUpperCase() || 'V1'} SYSTEM -
-
- TOTAL FORGERY RISK - {(Number(data.prob ?? data.score ?? 0) * 100).toFixed(1)}% RISK +

FORGERY RATIO

+ +
+ {/* 도넛 차트 */} + + {/* real(초록) 전체 배경 링 */} + + {/* fake(빨강) 비율만큼 덮어쓰기 */} + + {/* 중앙 fake % 텍스트 (rotate 보정) */} + + {fakeRatio.toFixed(0)}% + + + + {/* 범례 */} +
+
+ + FAKE + {fakeRatio.toFixed(1)}% +
+
+ + REAL + {realRatio.toFixed(1)}% +
+
+ {fakeCount} / {totalCount} FRAMES +
@@ -209,10 +248,10 @@ const VideoTimelinePage = ({ sessionUser }) => { {/* 하단: 타임라인 그래프 & 프레임 데이터 보드 */}
-

CHRONOLOGICAL FORGERY RISK MATRIX

+

CHRONOLOGICAL FORGERY RISK MATRIX

{isLoading ? ( -
CALCULATING FRAME-LEVEL METRICS...
+
CALCULATING FRAME-LEVEL METRICS...
) : (
{/* 차트 영역: 막대 + Canvas 꺾은선 */} @@ -239,7 +278,7 @@ const VideoTimelinePage = ({ sessionUser }) => { boxShadow: isFakeUnit ? '0 0 12px rgba(255,75,75,0.25)' : '0 0 12px rgba(57,255,20,0.15)' }} /> - {frame.timestamp} + {frame.timestamp}
); })}