ltmarx / web /src /components /RobustnessTest.tsx
harelcain's picture
Upload 16 files
44f463d verified
import { useState, useCallback } from 'react';
import { autoDetectMultiFrame, type DetectOptions } from '@core/detector.js';
import { attackReencode, attackDownscale, attackBrightness, attackContrast, attackSaturation, attackCrop } from '../lib/video-io.js';
type TestStatus = 'idle' | 'running' | 'pass' | 'fail' | 'error';
interface TestResult {
status: TestStatus;
confidence?: number;
payloadMatch?: boolean;
}
interface RobustnessTestProps {
blob: Blob;
width: number;
height: number;
payload: string;
secretKey: string;
}
interface TestDef {
label: string;
category: string;
run: (blob: Blob, w: number, h: number) => Promise<{ yPlanes: Uint8Array[]; width: number; height: number }>;
detectOptions?: DetectOptions;
}
const TESTS: TestDef[] = [
// CRF re-encoding
{ label: 'CRF 23', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 23, w, h) },
{ label: 'CRF 28', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 28, w, h) },
{ label: 'CRF 33', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 33, w, h) },
{ label: 'CRF 38', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 38, w, h) },
{ label: 'CRF 43', category: 'Re-encode', run: (b, w, h) => attackReencode(b, 43, w, h) },
// Downscale
{ label: '25%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 25, w, h) },
{ label: '50%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 50, w, h) },
{ label: '75%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 75, w, h) },
{ label: '90%', category: 'Downscale', run: (b, w, h) => attackDownscale(b, 90, w, h) },
// Brightness
{ label: '-0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, -0.2, w, h) },
{ label: '+0.2', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.2, w, h) },
{ label: '+0.4', category: 'Brightness', run: (b, w, h) => attackBrightness(b, 0.4, w, h) },
// Contrast
{ label: '0.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 0.5, w, h) },
{ label: '1.5x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 1.5, w, h) },
{ label: '2.0x', category: 'Contrast', run: (b, w, h) => attackContrast(b, 2.0, w, h) },
// Saturation
{ label: '0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0, w, h) },
{ label: '0.5x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 0.5, w, h) },
{ label: '2.0x', category: 'Saturation', run: (b, w, h) => attackSaturation(b, 2.0, w, h) },
// Crop (~5-10% from edges)
{ label: '5%', category: 'Crop', run: (b, w, h) => {
const px = Math.round(Math.min(w, h) * 0.05);
return attackCrop(b, px, px, px, px, w, h);
}, detectOptions: { cropResilient: true } },
{ label: '10%', category: 'Crop', run: (b, w, h) => {
const px = Math.round(Math.min(w, h) * 0.10);
return attackCrop(b, px, px, px, px, w, h);
}, detectOptions: { cropResilient: true } },
{ label: '15%', category: 'Crop', run: (b, w, h) => {
const px = Math.round(Math.min(w, h) * 0.15);
return attackCrop(b, px, px, px, px, w, h);
}, detectOptions: { cropResilient: true } },
{ label: '20%', category: 'Crop', run: (b, w, h) => {
const px = Math.round(Math.min(w, h) * 0.20);
return attackCrop(b, px, px, px, px, w, h);
}, detectOptions: { cropResilient: true } },
];
function payloadToHex(payload: Uint8Array): string {
return Array.from(payload).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
}
export default function RobustnessTest({ blob, width, height, payload, secretKey }: RobustnessTestProps) {
const [results, setResults] = useState<Record<number, TestResult>>({});
const [runningAll, setRunningAll] = useState(false);
const [progress, setProgress] = useState(0);
const expectedHex = payload.replace(/^0x/, '').toUpperCase();
const runTest = useCallback(async (idx: number) => {
setResults((prev) => ({ ...prev, [idx]: { status: 'running' } }));
try {
const test = TESTS[idx];
const attacked = await test.run(blob, width, height);
// Pipeline already caps at 30 frames; use up to 10 evenly spaced
const step = Math.max(1, Math.floor(attacked.yPlanes.length / 10));
const sampled = attacked.yPlanes.filter((_, i) => i % step === 0).slice(0, 10);
const detection = autoDetectMultiFrame(sampled, attacked.width, attacked.height, secretKey, test.detectOptions);
const detectedHex = detection.payload ? payloadToHex(detection.payload) : '';
const match = detection.detected && detectedHex === expectedHex;
setResults((prev) => ({
...prev,
[idx]: {
status: match ? 'pass' : 'fail',
confidence: detection.confidence,
payloadMatch: match,
},
}));
} catch (e) {
console.error(`Robustness test ${idx} error:`, e);
setResults((prev) => ({ ...prev, [idx]: { status: 'error' } }));
}
}, [blob, width, height, secretKey, expectedHex]);
const runAll = useCallback(async () => {
setRunningAll(true);
setProgress(0);
for (let i = 0; i < TESTS.length; i++) {
await runTest(i);
setProgress((i + 1) / TESTS.length);
}
setRunningAll(false);
}, [runTest]);
const categories = ['Re-encode', 'Downscale', 'Brightness', 'Contrast', 'Saturation', 'Crop'];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-zinc-300">Robustness Testing</h4>
<button
onClick={runAll}
disabled={runningAll}
className="rounded-lg bg-zinc-800 px-3 py-1.5 text-xs font-medium text-zinc-300
ring-1 ring-zinc-700 transition-all hover:bg-zinc-700 hover:text-zinc-100
disabled:cursor-not-allowed disabled:opacity-50"
>
{runningAll ? (
<span className="flex items-center gap-1.5">
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
{Math.round(progress * 100)}%
</span>
) : (
'Run All Tests'
)}
</button>
</div>
{runningAll && (
<div className="h-1 w-full overflow-hidden rounded-full bg-zinc-800">
<div
className="h-full rounded-full bg-zinc-500 transition-all duration-300"
style={{ width: `${progress * 100}%` }}
/>
</div>
)}
<div className="space-y-3">
{categories.map((cat) => {
const catTests = TESTS.map((t, i) => ({ ...t, idx: i })).filter((t) => t.category === cat);
return (
<div key={cat} className="flex items-center gap-2">
<span className="w-24 shrink-0 text-xs text-zinc-500">{cat}</span>
<div className="flex flex-wrap gap-1.5">
{catTests.map(({ label, idx }) => {
const r = results[idx];
const status = r?.status ?? 'idle';
return (
<button
key={idx}
onClick={() => runTest(idx)}
disabled={status === 'running' || runningAll}
className={`inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium
ring-1 transition-all disabled:cursor-not-allowed
${status === 'idle' ? 'bg-zinc-800/80 text-zinc-400 ring-zinc-700 hover:bg-zinc-700 hover:text-zinc-200' : ''}
${status === 'running' ? 'bg-zinc-800 text-zinc-400 ring-zinc-700' : ''}
${status === 'pass' ? 'bg-emerald-950/40 text-emerald-400 ring-emerald-800/50' : ''}
${status === 'fail' ? 'bg-red-950/40 text-red-400 ring-red-800/50' : ''}
${status === 'error' ? 'bg-amber-950/40 text-amber-400 ring-amber-800/50' : ''}
`}
>
{status === 'running' && (
<span className="h-3 w-3 animate-spin rounded-full border-[1.5px] border-zinc-500 border-t-zinc-200" />
)}
{status === 'pass' && <span>&#x2705;</span>}
{status === 'fail' && <span>&#x274C;</span>}
{status === 'error' && <span>&#x26A0;&#xFE0F;</span>}
{label}
{r?.confidence !== undefined && (
<span className="ml-0.5 opacity-70">{(r.confidence * 100).toFixed(0)}%</span>
)}
</button>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}