| import React from 'react'; |
| import { BarChart3, RotateCcw, X, Maximize2, Minimize2 } from 'lucide-react'; |
| import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer, Tooltip as RechartsTooltip, BarChart, Bar, XAxis, YAxis } from 'recharts'; |
| import TestDefinition from '../ui/TestDefinition'; |
|
|
| const API_BASE = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'; |
|
|
| const FeaturesTab = ({ |
| result, |
| expandedCards, |
| hiddenCards, |
| toggleExpand, |
| hideCard, |
| restoreCards, |
| getScoreColor, |
| isVideo, |
| }) => { |
| return ( |
| <> |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '1rem' }}> |
| <div style={{ flex: 1 }}><TestDefinition testId="features" /></div> |
| {Object.keys(hiddenCards).some(k => hiddenCards[k]) && ( |
| <button onClick={restoreCards} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', background: 'rgba(34, 211, 238, 0.1)', border: '1px solid var(--primary)', color: 'var(--primary)', padding: '0.5rem 1rem', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}> |
| <RotateCcw size={14} /> Restore Panels |
| </button> |
| )} |
| </div> |
| |
| <div className="analysis-grid" style={{ gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', alignItems: 'start' }}> |
| |
| {/* 1. DETECTOR SCORES (Col 1, spans 2 rows) */} |
| {!hiddenCards['detector'] && (<div className="glass-panel analysis-panel" style={{ gridRow: expandedCards['detector'] ? 'auto' : 'span 2', gridColumn: expandedCards['detector'] ? '1 / -1' : 'auto', height: '100%', display: 'flex', flexDirection: 'column', resize: 'both', overflow: 'hidden' }}> |
| <div className="panel-header" style={{ marginBottom: '1rem', paddingBottom: '0.75rem', position: 'relative' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> |
| <div className="panel-icon shap"><BarChart3 size={20} color="var(--primary)" /></div> |
| <div> |
| <div className="panel-title">Detector Scores</div> |
| <div className="panel-subtitle">Ensemble Inputs</div> |
| </div> |
| </div> |
| <div style={{ position: 'absolute', right: 0, top: 0, display: 'flex', gap: '0.5rem' }}> |
| <button onClick={() => toggleExpand('detector')} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: 0 }} title={expandedCards['detector'] ? "Restore Size" : "Expand Full Width"}> |
| {expandedCards['detector'] ? <Minimize2 size={14} /> : <Maximize2 size={14} />} |
| </button> |
| <button onClick={() => hideCard('detector')} style={{ background: 'transparent', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 0 }} title="Hide Panel"><X size={14} /></button> |
| </div> |
| </div> |
| <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.2rem' }}> |
| {[ |
| { label: 'EfficientNet-B4', score: result.nn_score, weight: result.weights?.nn_score }, |
| { label: 'Frequency Analysis', score: result.spectral_anomaly_score, weight: result.weights?.spectral_score }, |
| { label: 'Error Level Analysis', score: result.ela_score, weight: result.weights?.ela_score }, |
| { label: 'Face Geometry', score: result.geometry_anomaly_score, weight: result.weights?.geometry_anomaly }, |
| { label: 'Sensor Noise', score: result.noise_score, weight: result.weights?.noise_score }, |
| { label: 'Chrominance', score: result.color_score, weight: result.weights?.color_score }, |
| { label: 'Lighting', score: result.lighting_score, weight: result.weights?.lighting_score }, |
| { label: 'CFA Pattern', score: result.cfa_score || 0, weight: result.weights?.cfa_score }, |
| { label: 'Corneal Reflection', score: result.corneal_score || 0, weight: result.weights?.corneal_score }, |
| ...(isVideo ? [{ label: 'Eye Tracking', score: result.eye_score || 0, weight: result.weights?.eye_score }] : []), |
| ...(isVideo ? [{ label: 'Optical Flow', score: result.flow_score || 0, weight: result.weights?.flow_score }] : []), |
| ...(isVideo && result.file_metadata?.has_audio ? [{ label: 'Audio Desync', score: result.sync_score, weight: result.weights?.sync_score }] : []), |
| ...(isVideo && result.file_metadata?.has_audio ? [{ label: 'Voice Spoofing', score: result.voice_score || 0, weight: result.weights?.voice_score }] : []), |
| ...(isVideo ? [{ label: 'Pulse (rPPG)', score: result.rppg_score, weight: result.weights?.rppg_score }] : []) |
| ].map((item, idx) => ( |
| <div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.5rem 0', borderBottom: '1px solid rgba(255,255,255,0.04)' }}> |
| <div style={{ flex: 1, fontSize: '0.8rem', color: 'var(--text-secondary)' }}>{item.label}</div> |
| <div style={{ width: '40px', fontSize: '0.7rem', color: 'var(--text-muted)', textAlign: 'right' }}> |
| {item.weight !== undefined ? `${(item.weight * 100).toFixed(1)}%` : ''} |
| </div> |
| <div style={{ width: '100px' }}> |
| <div className="progress-bar-bg" style={{ height: '4px', margin: 0 }}> |
| <div className="progress-bar-fill" style={{ width: `${item.score * 100}%`, background: getScoreColor(item.score), animation: 'none' }}></div> |
| </div> |
| </div> |
| <div style={{ width: '45px', textAlign: 'right', fontSize: '0.85rem', fontWeight: 600, color: getScoreColor(item.score) }}> |
| {(item.score * 100).toFixed(0)}% |
| </div> |
| </div> |
| ))} |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 0', marginTop: 'auto', borderTop: '2px solid rgba(255,255,255,0.1)' }}> |
| <div style={{ flex: 1, fontSize: '0.9rem', fontWeight: 700, color: 'var(--text-main)' }}>Ensemble Score</div> |
| <div style={{ fontSize: '1.2rem', fontWeight: 800, color: getScoreColor(result.overall_score) }}> |
| {(result.overall_score * 100).toFixed(1)}% |
| </div> |
| </div> |
| </div> |
| </div>)} |
| |
| {/* 2. RADAR CHART (Col 2, Row 1) */} |
| {!hiddenCards['radar'] && (<div className="glass-panel analysis-panel" style={{ gridColumn: expandedCards['radar'] ? '1 / -1' : 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', resize: 'both', overflow: 'hidden', position: 'relative' }}> |
| <div style={{ position: 'absolute', right: '1rem', top: '1rem', display: 'flex', gap: '0.5rem', zIndex: 10 }}> |
| <button onClick={() => toggleExpand('radar')} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: 0 }} title={expandedCards['radar'] ? "Restore Size" : "Expand Full Width"}> |
| {expandedCards['radar'] ? <Minimize2 size={14} /> : <Maximize2 size={14} />} |
| </button> |
| <button onClick={() => hideCard('radar')} style={{ background: 'transparent', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 0 }} title="Hide Panel"><X size={14} /></button> |
| </div> |
| <h4 style={{ fontSize: '0.8rem', color: 'var(--text-main)', marginBottom: '1rem', textTransform: 'uppercase', letterSpacing: '1px', alignSelf: 'flex-start', fontWeight: 600 }}> |
| Fingerprint Radar |
| </h4> |
| <div style={{ position: 'relative', width: '100%', height: 260 }}> |
| <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}> |
| <RadarChart cx="50%" cy="50%" outerRadius="75%" data={[ |
| { subject: 'Neural Net', A: result.nn_score * 100 }, |
| { subject: 'Frequency', A: result.spectral_anomaly_score * 100 }, |
| { subject: 'ELA', A: result.ela_score * 100 }, |
| { subject: 'Geometry', A: result.geometry_anomaly_score * 100 }, |
| { subject: 'Noise', A: result.noise_score * 100 }, |
| { subject: 'Color', A: result.color_score * 100 }, |
| { subject: 'Lighting', A: result.lighting_score * 100 }, |
| { subject: 'CFA', A: (result.cfa_score || 0) * 100 }, |
| { subject: 'Corneal', A: (result.corneal_score || 0) * 100 }, |
| ...(isVideo ? [{ subject: 'Eye Gaze', A: (result.eye_score || 0) * 100 }] : []), |
| ...(isVideo ? [{ subject: 'Opt Flow', A: (result.flow_score || 0) * 100 }] : []), |
| ...(isVideo && result.file_metadata?.has_audio ? [{ subject: 'Desync', A: result.sync_score * 100 }] : []), |
| ...(isVideo && result.file_metadata?.has_audio ? [{ subject: 'Voice', A: (result.voice_score || 0) * 100 }] : []), |
| ...(isVideo ? [{ subject: 'Pulse', A: result.rppg_score * 100 }] : []) |
| ]}> |
| <PolarGrid stroke="rgba(255,255,255,0.1)" /> |
| <PolarAngleAxis dataKey="subject" tick={{ fill: 'var(--text-muted)', fontSize: 10, fontWeight: 600 }} /> |
| <PolarRadiusAxis angle={30} domain={[0, 100]} tick={false} axisLine={false} /> |
| <RechartsTooltip |
| contentStyle={{ backgroundColor: 'rgba(10,15,30,0.95)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px' }} |
| itemStyle={{ color: 'var(--danger)' }} |
| formatter={(value) => [`${value.toFixed(1)}%`, 'Anomaly Score']} |
| /> |
| <Radar name="Anomaly" dataKey="A" stroke="#ef4444" fill="rgba(239, 68, 68, 0.4)" fillOpacity={0.6} /> |
| </RadarChart> |
| </ResponsiveContainer> |
| </div> |
| <div style={{ fontSize: '0.7rem', color: 'var(--text-muted)', marginTop: '0.5rem', textAlign: 'center' }}> |
| A larger footprint strongly indicates AI generation. |
| </div> |
| </div>)} |
| |
| {/* 3. SHAP (Col 3, Row 1) */} |
| {!hiddenCards['shap'] && (<div className="glass-panel analysis-panel" style={{ gridColumn: expandedCards['shap'] ? '1 / -1' : 'auto', height: '100%', display: 'flex', flexDirection: 'column', resize: 'both', overflow: 'hidden' }}> |
| <div className="panel-header" style={{ marginBottom: '1rem', paddingBottom: '0.75rem', position: 'relative' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> |
| <div className="panel-icon shap"><BarChart3 size={20} color="var(--success)" /></div> |
| <div> |
| <div className="panel-title">SHAP Importance</div> |
| <div className="panel-subtitle">Top drivers</div> |
| </div> |
| </div> |
| <div style={{ position: 'absolute', right: 0, top: 0, display: 'flex', gap: '0.5rem' }}> |
| <button onClick={() => toggleExpand('shap')} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: 0 }} title={expandedCards['shap'] ? "Restore Size" : "Expand Full Width"}> |
| {expandedCards['shap'] ? <Minimize2 size={14} /> : <Maximize2 size={14} />} |
| </button> |
| <button onClick={() => hideCard('shap')} style={{ background: 'transparent', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 0 }} title="Hide Panel"><X size={14} /></button> |
| </div> |
| </div> |
| <div style={{ position: 'relative', width: '100%', height: 200, marginTop: '1rem', flex: 1 }}> |
| <ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}> |
| <BarChart data={result.shap_top_features.map((feature, idx) => ({ |
| name: feature.length > 20 ? feature.substring(0, 18) + '...' : feature, |
| fullName: feature, |
| importance: Math.max(10, 100 - (idx * 20)) |
| }))} layout="vertical" margin={{ top: 0, right: 10, left: 0, bottom: 0 }}> |
| <XAxis type="number" hide domain={[0, 160]} /> |
| <YAxis type="category" dataKey="name" axisLine={false} tickLine={false} tick={{ fill: 'var(--text-secondary)', fontSize: 11 }} width={120} /> |
| <RechartsTooltip |
| cursor={{ fill: 'rgba(255,255,255,0.05)' }} |
| allowEscapeViewBox={{ x: true, y: true }} |
| wrapperStyle={{ zIndex: 1000 }} |
| content={({ active, payload }) => { |
| if (active && payload && payload.length) { |
| return ( |
| <div style={{ backgroundColor: 'rgba(10,15,30,0.95)', border: '1px solid rgba(255,255,255,0.1)', padding: '0.75rem', borderRadius: '8px', maxWidth: '200px', whiteSpace: 'normal', wordWrap: 'break-word', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.5)' }}> |
| <p style={{ color: 'var(--text-main)', margin: '0 0 0.5rem 0', fontSize: '0.8rem', fontWeight: 600, lineHeight: '1.4' }}>{payload[0].payload.fullName}</p> |
| <p style={{ color: 'var(--success)', margin: 0, fontSize: '0.85rem', fontWeight: 700 }}>Importance: {payload[0].value}%</p> |
| </div> |
| ); |
| } |
| return null; |
| }} |
| /> |
| <Bar dataKey="importance" fill="var(--success)" radius={[0, 4, 4, 0]} barSize={16} /> |
| </BarChart> |
| </ResponsiveContainer> |
| </div> |
| </div>)} |
| |
| {/* 4. ARCHITECTURE (Col 2 & 3, Row 2) */} |
| {!hiddenCards['arch'] && (<div className="glass-panel analysis-panel" style={{ gridColumn: expandedCards['arch'] ? '1 / -1' : 'span 2', height: '100%', display: 'flex', flexDirection: 'column', gap: '1rem', resize: 'both', overflow: 'hidden' }}> |
| <div className="panel-header" style={{ marginBottom: 0, position: 'relative' }}> |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> |
| <div className="panel-icon" style={{ background: 'rgba(59,130,246,0.12)' }}><BarChart3 size={20} color="var(--primary)" /></div> |
| <div> |
| <div className="panel-title">Meta-Classifier Architecture</div> |
| <div className="panel-subtitle">PyTorch Tabular ResNet + Self-Attention</div> |
| </div> |
| </div> |
| <div style={{ position: 'absolute', right: 0, top: 0, display: 'flex', gap: '0.5rem' }}> |
| <button onClick={() => toggleExpand('arch')} style={{ background: 'transparent', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', padding: 0 }} title={expandedCards['arch'] ? "Restore Size" : "Expand Full Width"}> |
| {expandedCards['arch'] ? <Minimize2 size={14} /> : <Maximize2 size={14} />} |
| </button> |
| <button onClick={() => hideCard('arch')} style={{ background: 'transparent', border: 'none', color: 'var(--danger)', cursor: 'pointer', padding: 0 }} title="Hide Panel"><X size={14} /></button> |
| </div> |
| </div> |
| <div style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', lineHeight: '1.5', flex: 1, minWidth: 0, wordWrap: 'break-word', whiteSpace: 'normal' }}> |
| <p style={{ margin: '0 0 0.5rem 0' }}>The Meta-Classifier acts as the final "Judge". It does not look at the video pixels; instead, it analyzes the <strong>numerical scores</strong> generated by all the independent physical and biological sensors.</p> |
| <ul style={{ margin: '0', paddingLeft: '1.5rem', display: 'flex', flexDirection: 'column', gap: '0.25rem' }}> |
| <li><strong>Inputs:</strong> 12-14 distinct anomaly scores (0.0 to 1.0).</li> |
| <li><strong>Self-Attention:</strong> Learns which sensors to trust based on the context (e.g. ignoring color anomalies if the video is black and white).</li> |
| <li><strong>XAI Override:</strong> Hard-coded to automatically override the neural network and flag the video as a Deepfake if any critical biological sensor (like Geometry) exceeds 70% anomaly.</li> |
| </ul> |
| </div> |
| </div>)} |
| |
| </div> |
| </div> |
| </> |
| ); |
| }; |
|
|
| export default React.memo(FeaturesTab); |
|
|