Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Download interface/static/index.html from SMSD75/SynthScope: direct link, hf CLI and curl.
- Browser
- Download file 80.7 kB
-
https://huggingface.co/spaces/SMSD75/SynthScope/resolve/main/interface/static/index.html
- Command line
-
hf download hf://spaces/SMSD75/SynthScope/interface/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/SMSD75/SynthScope/resolve/main/interface/static/index.html
80.7 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>SynthScope — AI-Generated Media Detector</title> | |
| <style> | |
| /* ============================================================================ | |
| SynthScope web interface — visual theme | |
| Modern dark "glass dashboard": layered navy gradient, frosted panels, an | |
| indigo→cyan accent, soft shadows, and smooth motion. Class names are kept | |
| stable so App.jsx is untouched. | |
| ========================================================================== */ | |
| :root { | |
| --bg: #070b16; | |
| --bg2: #0c1326; | |
| --panel: rgba(22, 29, 51, 0.72); | |
| --panel-solid: #161d33; | |
| --panel2: rgba(31, 41, 71, 0.55); | |
| --line: rgba(120, 140, 190, 0.16); | |
| --line-strong: rgba(120, 140, 190, 0.30); | |
| --text: #eef2fb; | |
| --muted: #9aa7c7; | |
| --muted2: #6b7796; | |
| --accent: #7c8cff; | |
| --accent2: #46d6ff; | |
| --accent-grad: linear-gradient(135deg, #7c8cff 0%, #46d6ff 100%); | |
| --real: #34d399; | |
| --fake: #ff6b7d; | |
| --radius: 16px; | |
| --radius-sm: 10px; | |
| --shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.65); | |
| --shadow-lg: 0 24px 60px -20px rgba(0, 0, 0, 0.75); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| color: var(--text); | |
| font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; | |
| -webkit-font-smoothing: antialiased; | |
| background: | |
| radial-gradient(1100px 600px at 85% -10%, rgba(70, 214, 255, 0.10), transparent 60%), | |
| radial-gradient(1000px 700px at -5% 0%, rgba(124, 140, 255, 0.14), transparent 55%), | |
| linear-gradient(180deg, var(--bg2) 0%, var(--bg) 100%); | |
| background-attachment: fixed; | |
| min-height: 100vh; | |
| } | |
| .wrap { max-width: 1100px; margin: 0 auto; padding: 40px 22px 80px; } | |
| /* ---- header ---------------------------------------------------------------*/ | |
| header { margin-bottom: 6px; } | |
| header h1 { | |
| margin: 0 0 6px; font-size: 34px; font-weight: 800; letter-spacing: -0.02em; | |
| background: var(--accent-grad); -webkit-background-clip: text; | |
| background-clip: text; -webkit-text-fill-color: transparent; | |
| } | |
| header p { margin: 0; color: var(--muted); font-size: 15px; max-width: 720px; line-height: 1.5; } | |
| /* ---- layout ---------------------------------------------------------------*/ | |
| .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 26px; } | |
| @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } } | |
| .card { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: var(--radius); | |
| padding: 22px; | |
| box-shadow: var(--shadow); | |
| backdrop-filter: blur(14px) saturate(120%); | |
| -webkit-backdrop-filter: blur(14px) saturate(120%); | |
| } | |
| .card h2 { | |
| margin: 0 0 14px; font-size: 12px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: 0.12em; font-weight: 700; | |
| } | |
| label { display: block; font-size: 13px; color: var(--muted); margin: 14px 0 7px; font-weight: 500; } | |
| select, input[type=text], input[type=number] { | |
| width: 100%; padding: 11px 12px; border-radius: var(--radius-sm); | |
| background: var(--panel2); color: var(--text); border: 1px solid var(--line-strong); | |
| font-size: 14px; transition: border-color .15s, box-shadow .15s; outline: none; | |
| } | |
| select:focus, input[type=text]:focus, input[type=number]:focus { | |
| border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 140, 255, 0.22); | |
| } | |
| option { background: var(--panel-solid); } | |
| option:disabled { color: #5a647e; } | |
| /* ---- upload / drop --------------------------------------------------------*/ | |
| .drop { | |
| border: 1.5px dashed var(--line-strong); border-radius: var(--radius); | |
| padding: 30px; text-align: center; cursor: pointer; color: var(--muted); | |
| transition: border-color .18s, background .18s, color .18s, transform .18s; | |
| } | |
| .drop:hover { border-color: var(--accent); color: var(--text); } | |
| .drop.drag { border-color: var(--accent2); background: rgba(70, 214, 255, 0.08); color: var(--text); transform: scale(1.01); } | |
| .preview { | |
| margin-top: 16px; border-radius: var(--radius-sm); overflow: hidden; background: #000; | |
| max-height: 320px; display: flex; justify-content: center; border: 1px solid var(--line); | |
| } | |
| .preview img, .preview video { max-width: 100%; max-height: 320px; display: block; } | |
| /* ---- buttons --------------------------------------------------------------*/ | |
| button.go, button.sm, button.primary { | |
| border: 0; border-radius: var(--radius-sm); background: var(--accent-grad); | |
| color: #06122b; font-weight: 700; cursor: pointer; | |
| box-shadow: 0 8px 20px -8px rgba(124, 140, 255, 0.6); | |
| transition: transform .12s ease, box-shadow .12s ease, opacity .12s; | |
| } | |
| button.go { margin-top: 18px; width: 100%; padding: 13px; font-size: 15px; } | |
| button.sm { padding: 11px 16px; white-space: nowrap; } | |
| button.primary { padding: 9px 18px; } | |
| button.go:hover:not(:disabled), button.sm:hover:not(:disabled), button.primary:hover:not(:disabled) { | |
| transform: translateY(-1px); box-shadow: 0 12px 26px -8px rgba(70, 214, 255, 0.55); | |
| } | |
| button.go:active, button.sm:active { transform: translateY(0); } | |
| button.go:disabled, button.sm:disabled, button.primary:disabled { opacity: .4; cursor: not-allowed; box-shadow: none; } | |
| .meta { font-size: 12px; color: var(--muted); margin-top: 12px; line-height: 1.6; } | |
| /* ---- verdict / probability ------------------------------------------------*/ | |
| .verdict { font-size: 38px; font-weight: 800; text-align: center; margin: 6px 0 2px; letter-spacing: -0.02em; white-space: nowrap; } | |
| .explore .verdict { font-size: 28px; } | |
| .verdict.real { color: var(--real); text-shadow: 0 0 28px rgba(52, 211, 153, 0.35); } | |
| .verdict.fake { color: var(--fake); text-shadow: 0 0 28px rgba(255, 107, 125, 0.35); } | |
| .bar { | |
| position: relative; height: 28px; border-radius: 9px; overflow: hidden; display: flex; | |
| background: var(--panel2); border: 1px solid var(--line); margin: 16px 0 6px; | |
| } | |
| .bar .fake { background: linear-gradient(90deg, #ff6b7d, #ff8a5d); } | |
| .bar .real { background: linear-gradient(90deg, #34d399, #46d6ff); } | |
| .bar span { | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 12px; font-weight: 700; color: #07101f; white-space: nowrap; transition: flex-basis .4s ease; | |
| } | |
| .bar .fake { justify-content: flex-start; padding-left: 12px; } | |
| .bar .real { justify-content: flex-end; padding-right: 12px; } | |
| .bar .thrline { position: absolute; top: -3px; bottom: -3px; width: 2px; background: #fff; box-shadow: 0 0 6px rgba(255,255,255,.6); } | |
| .row { | |
| display: flex; justify-content: space-between; font-size: 13px; | |
| padding: 8px 0; border-bottom: 1px solid var(--line); color: var(--muted); | |
| } | |
| .row:last-child { border-bottom: 0; } | |
| .row b { color: var(--text); } | |
| /* ---- per-frame sparkline --------------------------------------------------*/ | |
| .frames { margin-top: 14px; } | |
| .spark { | |
| position: relative; display: flex; gap: 2px; align-items: flex-end; height: 64px; margin-top: 8px; | |
| padding: 4px 4px 0; background: var(--panel2); border: 1px solid var(--line); border-radius: var(--radius-sm); | |
| } | |
| .spark i { flex: 1; background: var(--accent-grad); border-radius: 3px 3px 0 0; min-height: 3px; opacity: .9; } | |
| .spark .thr50 { position: absolute; left: 6px; right: 6px; top: 50%; height: 0; border-top: 1px dashed #4a5572; pointer-events: none; } | |
| .frame-stats { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 5px; } | |
| .frame-stats b { color: var(--text); } | |
| .err { color: var(--fake); font-size: 13px; margin-top: 12px; white-space: pre-wrap; } | |
| .pill { | |
| display: inline-block; font-size: 11px; padding: 2px 9px; border-radius: 999px; | |
| background: var(--panel2); border: 1px solid var(--line-strong); color: var(--muted); margin-left: 8px; | |
| } | |
| .empty { color: var(--muted2); text-align: center; padding: 48px 0; font-size: 14px; } | |
| .notice { border-left: 3px solid var(--accent, #7c9cff); } | |
| .notice h3 { margin: 0 0 8px; font-size: 16px; } | |
| .notice p { margin: 8px 0; line-height: 1.55; color: var(--muted); } | |
| .notice b, .notice i { color: inherit; } | |
| .notice.slim { padding: 10px 14px; font-size: 13px; color: var(--muted); margin-bottom: 12px; } | |
| .notice a { color: var(--accent, #7c9cff); } | |
| /* ---- tabs -----------------------------------------------------------------*/ | |
| .tabs { display: flex; gap: 4px; margin-top: 26px; border-bottom: 1px solid var(--line); flex-wrap: wrap; } | |
| .tab { | |
| background: transparent; border: 0; color: var(--muted); padding: 11px 18px; | |
| cursor: pointer; font-size: 14px; font-weight: 600; border-bottom: 2px solid transparent; | |
| transition: color .15s, border-color .15s; | |
| } | |
| .tab:hover { color: var(--text); } | |
| .tab.active { color: var(--text); border-bottom-color: transparent; | |
| background: var(--accent-grad); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; | |
| border-bottom: 2px solid var(--accent); } | |
| /* ---- data explorer --------------------------------------------------------*/ | |
| .toolbar { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } | |
| .toolbar > div { flex: 1; min-width: 150px; } | |
| .badge { | |
| display: inline-block; font-size: 10px; padding: 1px 8px; border-radius: 7px; | |
| background: var(--accent-grad); color: #06122b; font-weight: 700; margin-left: 6px; vertical-align: middle; | |
| } | |
| .thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 11px; margin-top: 18px; } | |
| .thumb { | |
| position: relative; border-radius: var(--radius-sm); overflow: hidden; cursor: pointer; | |
| border: 2px solid transparent; background: #000; aspect-ratio: 1 / 1; transition: transform .15s, border-color .15s; | |
| } | |
| .thumb:hover { transform: translateY(-2px); } | |
| .thumb.sel { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 140, 255, 0.25); } | |
| .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; } | |
| .thumb .tag { position: absolute; top: 5px; left: 5px; font-size: 10px; padding: 1px 7px; border-radius: 6px; font-weight: 700; letter-spacing: .03em; } | |
| .thumb .tag.real { background: var(--real); color: #06210f; } | |
| .thumb .tag.fake { background: var(--fake); color: #2a0707; } | |
| .thumb .pred { position: absolute; bottom: 0; left: 0; right: 0; font-size: 10px; text-align: center; padding: 2px 0; font-weight: 700; } | |
| .thumb .pred.ok { background: rgba(52, 211, 153, .92); color: #06210f; } | |
| .thumb .pred.no { background: rgba(255, 107, 125, .92); color: #fff; } | |
| .thumb .play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; color: rgba(255,255,255,.85); pointer-events: none; text-shadow: 0 2px 8px rgba(0,0,0,.6); } | |
| /* ---- threshold + calibration ----------------------------------------------*/ | |
| .thr { margin-top: 16px; padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--panel2); } | |
| .thr input[type=range] { width: 100%; accent-color: var(--accent); } | |
| .thr .lab { font-size: 12px; color: var(--muted); display: flex; justify-content: space-between; } | |
| .thr .lab b { color: var(--text); } | |
| .calib { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 12px; } | |
| .calib button { padding: 8px 12px; border: 1px solid var(--line-strong); border-radius: 8px; background: var(--panel2); color: var(--text); font-size: 12px; font-weight: 600; cursor: pointer; transition: border-color .15s; } | |
| .calib button:hover:not(:disabled) { border-color: var(--accent); } | |
| .calib button:disabled { opacity: .45; cursor: not-allowed; } | |
| .calib input[type=number] { width: 64px; padding: 6px; } | |
| .stat { font-size: 12px; color: var(--muted); margin-top: 8px; } | |
| .stat b { color: var(--text); } | |
| /* ---- per-model paper-vs-reproduced table ----------------------------------*/ | |
| table.repro { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 12px; } | |
| table.repro td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| table.repro td:first-child { color: var(--muted); width: 92px; white-space: nowrap; } | |
| table.repro td.match-match { color: var(--real); } | |
| table.repro td.match-gap-closed { color: var(--real); font-weight: 600; } | |
| table.repro td.match-below, table.repro td.match-partial, table.repro td.match-ranking-only { color: #ffcf6e; } | |
| table.repro td.match-pending { color: var(--muted); font-style: italic; } | |
| table.repro td.rnote { color: var(--muted); font-size: 11px; font-style: italic; } | |
| /* ---- full per-benchmark results table -------------------------------------*/ | |
| .repro-full { width: 100%; margin-top: 8px; border-collapse: collapse; font-size: 12px; } | |
| .repro-full th { text-align: left; border-bottom: 1px solid var(--line-strong); padding: 4px 7px; color: var(--muted); } | |
| .repro-full td { padding: 4px 7px; border-bottom: 1px solid var(--line); vertical-align: top; } | |
| .repro-full td .variant { font-size: 11px; color: var(--muted); margin-top: 2px; max-width: 320px; } | |
| .status-reproduced { color: var(--real); font-weight: 600; } | |
| .status-official { color: var(--accent); font-weight: 600; } | |
| .status-below { color: #ffb74d; } | |
| .status-missing { color: var(--muted2); } | |
| .status-reference { color: var(--muted2); font-style: italic; } | |
| .repro-wrap { margin: 10px 0 4px; } | |
| .repro-details { border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--panel2); overflow: hidden; } | |
| .repro-details > summary { cursor: pointer; list-style: none; padding: 10px 13px; font-size: 12px; color: var(--accent); font-weight: 600; user-select: none; display: flex; align-items: center; gap: 7px; } | |
| .repro-details > summary::-webkit-details-marker { display: none; } | |
| .repro-details > summary::before { content: "▸"; font-size: 10px; transition: transform .15s; } | |
| .repro-details[open] > summary::before { transform: rotate(90deg); } | |
| .repro-scroll { max-height: 360px; overflow: auto; border-top: 1px solid var(--line); } | |
| .repro-full thead th { position: sticky; top: 0; z-index: 1; background: var(--panel-solid); white-space: nowrap; } | |
| .repro-full td:first-child { min-width: 170px; } | |
| .repro-full tbody tr:nth-child(even) { background: rgba(255,255,255,.02); } | |
| .repro-full tbody tr:hover { background: rgba(124, 140, 255, .07); } | |
| .repro-legend { color: var(--muted); font-size: 11px; margin: 8px 12px 10px; line-height: 1.55; } | |
| .lead-head { display: flex; flex-wrap: wrap; gap: 14px; align-items: flex-end; justify-content: space-between; margin: 6px 0 16px; } | |
| .lead-head .muted { flex: 1; min-width: 280px; color: var(--muted); } | |
| .lead-head label { color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; } | |
| .lead-head select { width: auto; min-width: 220px; } | |
| .lead-card { margin-bottom: 18px; } | |
| .card.lead-card h2, .topgen .card h2 { display: flex; align-items: center; gap: 8px; text-transform: none; letter-spacing: 0; font-size: 17px; color: var(--text); } | |
| .lead-bn { color: var(--muted); font-size: 10px; margin-top: 2px; } | |
| .lb-best { color: var(--accent2); font-weight: 700; } | |
| table.repro-full.lb-gen td { white-space: nowrap; } | |
| table.repro-full.lb-gen td:nth-child(2) { white-space: normal; } | |
| .lb-top td { background: rgba(124, 140, 255, .08); } | |
| .topgen .card { margin-bottom: 18px; } | |
| /* ---- SOTA generator vs detector comparison --------------------------------*/ | |
| .sota .sota-intro { margin: 18px 0 4px; max-width: 760px; line-height: 1.5; } | |
| .sota-layout { display: grid; grid-template-columns: 340px 1fr; gap: 22px; margin-top: 14px; align-items: start; } | |
| @media (max-width: 900px) { .sota-layout { grid-template-columns: 1fr; } } | |
| .sota-right { display: flex; flex-direction: column; gap: 22px; min-width: 0; } | |
| .sota .step { display: flex; align-items: center; gap: 9px; margin: 20px 0 9px; font-size: 14px; } | |
| .sota .step:first-of-type { margin-top: 2px; } | |
| .sota .stepno { width: 22px; height: 22px; border-radius: 50%; background: var(--accent-grad); color: #06122b; | |
| font-size: 12px; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; } | |
| .sota .srcbox { height: 190px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: rgba(0,0,0,.28); | |
| display: flex; align-items: center; justify-content: center; overflow: hidden; } | |
| .sota .srcbox img { max-width: 100%; max-height: 100%; display: block; } | |
| .sota .srcempty { color: var(--muted2); font-size: 13px; } | |
| .sota .row2 { display: flex; gap: 10px; margin-top: 10px; } | |
| .sota .row2 .sm { flex: 1; text-align: center; margin: 0; font-size: 13px; padding: 10px 12px; } | |
| .sota label.ghost { background: var(--panel2); color: var(--text); border: 1px solid var(--line-strong); box-shadow: none; | |
| cursor: pointer; border-radius: var(--radius-sm); font-weight: 600; } | |
| .sota textarea { width: 100%; resize: vertical; padding: 11px 12px; border-radius: var(--radius-sm); background: var(--panel2); | |
| color: var(--text); border: 1px solid var(--line-strong); font: inherit; font-size: 14px; outline: none; } | |
| .sota textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124,140,255,.22); } | |
| .sota .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| .sota .pair figure { margin: 0; min-width: 0; } | |
| .sota .imgbox { height: 300px; border: 1px solid var(--line-strong); border-radius: var(--radius-sm); background: rgba(0,0,0,.28); | |
| display: flex; align-items: center; justify-content: center; overflow: hidden; color: var(--muted2); font-size: 13px; text-align: center; padding: 8px; } | |
| .sota .imgbox img { max-width: 100%; max-height: 100%; display: block; } | |
| .sota figcaption { text-align: center; font-size: 12px; color: var(--muted); margin-top: 8px; } | |
| .sota h2 .muted { text-transform: none; letter-spacing: 0; font-weight: 500; margin-left: 8px; } | |
| .sota .detbar { display: flex; gap: 8px; margin: -4px 0 6px; } | |
| .sota .chipbtn { background: transparent; border: 1px solid var(--line-strong); color: var(--muted); border-radius: 999px; | |
| padding: 3px 12px; font-size: 12px; cursor: pointer; } | |
| .sota .chipbtn:hover { color: var(--text); border-color: var(--accent); } | |
| .sota .detgroup { margin-top: 14px; } | |
| .sota .detlabel { font-size: 11px; color: var(--muted2); text-transform: uppercase; letter-spacing: .1em; margin-bottom: 7px; } | |
| .sota .chips { display: flex; flex-wrap: wrap; gap: 8px; } | |
| .sota .chip { background: var(--panel2); color: var(--muted); border: 1px solid var(--line-strong); border-radius: 999px; | |
| padding: 6px 14px; font-size: 13px; cursor: pointer; transition: all .12s; } | |
| .sota .chip:hover:not(:disabled) { color: var(--text); border-color: var(--accent); } | |
| .sota .chip.on { background: rgba(124,140,255,.20); color: var(--text); border-color: var(--accent); font-weight: 600; } | |
| .sota .chip:disabled { opacity: .35; cursor: not-allowed; text-decoration: line-through; } | |
| .sota .results { margin-top: 20px; border-top: 1px solid var(--line); padding-top: 14px; } | |
| .sota .sumline { font-size: 14px; margin-bottom: 10px; min-height: 20px; } | |
| .sota .resrow { display: grid; grid-template-columns: 150px 1fr 150px 84px; gap: 12px; align-items: center; padding: 8px 0; | |
| border-bottom: 1px solid var(--line); font-size: 13px; } | |
| @media (max-width: 700px) { .sota .resrow { grid-template-columns: 1fr 1fr; } } | |
| .sota .rname { font-weight: 600; } | |
| .sota .rbar { position: relative; height: 9px; background: rgba(255,255,255,.07); border-radius: 5px; overflow: visible; } | |
| .sota .rfill { height: 100%; border-radius: 5px; background: var(--real); } | |
| .sota .resrow.hit .rfill { background: var(--fake); } | |
| .sota .rthr { position: absolute; left: 50%; top: -3px; bottom: -3px; width: 1px; background: var(--muted); opacity: .7; } | |
| .sota .rnum { font-variant-numeric: tabular-nums; } | |
| .sota .rnum .muted { font-size: 11px; margin-left: 6px; } | |
| .sota .rbadge { text-align: right; font-weight: 700; } | |
| .sota .resrow.hit .rbadge { color: var(--fake); } | |
| .sota .resrow.miss .rbadge { color: var(--real); } | |
| .sota .rerr { grid-column: 2 / -1; color: var(--fake); font-size: 12px; } | |
| /* ---- weight-source badge --------------------------------------------------*/ | |
| .wtag { font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 8px; border: 1px solid var(--line-strong); color: var(--muted); white-space: nowrap; vertical-align: middle; } | |
| .wtag-official { background: rgba(46, 125, 82, .22); border-color: #2e7d52; color: #8ee0b0; } | |
| .wtag-synthscope_trained { background: rgba(46, 93, 125, .22); border-color: #2e5d7d; color: #8ec6e0; } | |
| .wtag-training_free { background: rgba(125, 102, 46, .22); border-color: #7d662e; color: #e0cf8e; } | |
| /* links + scrollbars --------------------------------------------------------*/ | |
| a { color: var(--accent); } | |
| ::-webkit-scrollbar { width: 10px; height: 10px; } | |
| ::-webkit-scrollbar-track { background: transparent; } | |
| ::-webkit-scrollbar-thumb { background: var(--line-strong); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--accent); background-clip: padding-box; } | |
| /* ---- interpretability map -------------------------------------------------*/ | |
| .interp { margin-top: 16px; } | |
| .interp-row { display: flex; gap: 12px; margin-top: 8px; flex-wrap: wrap; } | |
| .interp-cell { margin: 0; } | |
| .interp-cell figcaption { font-size: 11px; color: #8b93a7; margin-top: 4px; text-align: center; } | |
| .interp-map { display: block; width: 100%; max-width: 240px; border-radius: var(--radius-sm); | |
| border: 1px solid var(--line-strong); box-shadow: var(--shadow); } | |
| /* ---- polish: header, collapsible details, badges, page heads ---------------*/ | |
| header.top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; } | |
| header.top h1 { font-size: 38px; } | |
| header.top .pill { margin-top: 10px; white-space: nowrap; } | |
| details.adv { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: rgba(31,41,71,.35); } | |
| details.adv > summary { cursor: pointer; padding: 10px 14px; font-size: 13px; color: var(--muted); font-weight: 600; list-style: none; user-select: none; } | |
| details.adv > summary::-webkit-details-marker { display: none; } | |
| details.adv > summary::before { content: "▸"; display: inline-block; width: 16px; } | |
| details.adv[open] > summary::before { content: "▾"; } | |
| details.adv > summary:hover { color: var(--text); } | |
| details.adv > .adv-body { padding: 0 14px 14px; } | |
| details.adv .thr { margin-top: 6px; border: 0; padding: 0; background: none; } | |
| .pagehead { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin: 18px 0 14px; flex-wrap: wrap; } | |
| .pagehead p { margin: 0; max-width: 720px; line-height: 1.5; } | |
| select.inline { width: auto; min-width: 240px; } | |
| .sbadge { font-size: 12px; font-weight: 600; white-space: nowrap; } | |
| details.lead-card { margin-bottom: 14px; padding: 0; } | |
| details.lead-card > summary { cursor: pointer; list-style: none; padding: 16px 22px; display: flex; align-items: center; gap: 10px; user-select: none; } | |
| details.lead-card > summary::-webkit-details-marker { display: none; } | |
| details.lead-card > summary::before { content: "▸"; color: var(--muted); } | |
| details.lead-card[open] > summary::before { content: "▾"; } | |
| details.lead-card .sumtitle { font-size: 16px; font-weight: 700; } | |
| details.lead-card .repro-scroll { padding: 0 22px 18px; } | |
| .explore .detbar2 { padding-bottom: 16px; } | |
| .explore .detsel label { margin-top: 0; } | |
| .explore .thumbs { margin-top: 16px; } | |
| .explore .stat { margin-top: 14px; font-size: 13px; } | |
| /* ---- input note, centered image boxes, heatmap legend --------------------------------------------------------*/ | |
| .inote { margin-top: 8px; font-size: 12.5px; color: var(--muted); line-height: 1.5; } | |
| .itag { display: inline-block; font-size: 11px; font-weight: 700; padding: 1px 9px; border-radius: 999px; margin-right: 6px; | |
| background: rgba(124,140,255,.18); border: 1px solid var(--accent); color: var(--text); white-space: nowrap; } | |
| .preview { align-items: center; min-height: 200px; } | |
| .interp-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; } | |
| .interp-row { justify-content: center; flex-wrap: nowrap; gap: 12px; } | |
| .interp-row .interp-cell { flex: 1 1 0; min-width: 0; } | |
| .interp-cell { text-align: center; } | |
| .imgcell { display: flex; align-items: center; justify-content: center; min-height: 140px; min-width: 0; | |
| background: rgba(0,0,0,.28); border: 1px solid var(--line-strong); border-radius: var(--radius-sm); padding: 6px; } | |
| .imgcell .interp-map { max-width: 100%; max-height: 220px; width: auto; border: 0; box-shadow: none; } | |
| .legend { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); margin-top: 10px; } | |
| .legbar { flex: 0 0 120px; height: 9px; border-radius: 5px; | |
| background: linear-gradient(90deg, #00007f, #0000ff, #00ffff, #7fff7f, #ffff00, #ff0000, #7f0000); } | |
| .seen { margin-top: 18px; } | |
| .seen-title { font-size: 14px; font-weight: 700; margin-bottom: 8px; text-align: center; } | |
| .seenbox { min-height: 200px; width: 100%; } | |
| .seenbox img { max-width: 100%; max-height: 300px; image-rendering: auto; } | |
| .seen-cap { text-align: center; line-height: 1.55; margin-top: 8px; } | |
| /* ---- generator modes + similarity -----------------------------------------------------------------------------*/ | |
| .sota .modes { display: flex; gap: 8px; flex-wrap: wrap; } | |
| .sota .modebtn { flex: 1 1 140px; background: var(--panel2); color: var(--muted); border: 1px solid var(--line-strong); | |
| border-radius: var(--radius-sm); padding: 9px 10px; font-size: 13px; font-weight: 600; cursor: pointer; } | |
| .sota .modebtn.on { background: rgba(124,140,255,.20); color: var(--text); border-color: var(--accent); } | |
| .sota .modebtn:disabled { opacity: .35; cursor: not-allowed; } | |
| .sota .lab2 { margin: 12px 0 6px; } | |
| .sota .presets { margin-top: 8px; } | |
| .sota .sim { margin-top: 12px; padding: 9px 12px; border-radius: var(--radius-sm); font-size: 13px; line-height: 1.5; border: 1px solid var(--line-strong); } | |
| .sota .sim.warn { border-color: #ffcf6e; background: rgba(255,207,110,.08); color: #ffe0a0; } | |
| .sota .sim.ok { border-color: rgba(52,211,153,.5); background: rgba(52,211,153,.07); } | |
| /* ---- benchmark pages ------------------------------------------------------------------------------------------*/ | |
| .coverage { margin: 8px 0 16px; padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: rgba(31,41,71,.35); font-size: 14px; } | |
| .coverage .covchips { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 6px; } | |
| .coverage .covchips .sbadge { padding: 3px 12px; border: 1px solid var(--line-strong); border-radius: 999px; background: var(--panel2); } | |
| .coverage a, .topgen a { color: var(--accent2); } | |
| .topgen .ovtable .ovcell { text-align: center; font-variant-numeric: tabular-nums; } | |
| .topgen h2 .muted { text-transform: none; letter-spacing: 0; font-weight: 500; margin-left: 8px; } | |
| pre.cmd { margin: 10px 0 0; padding: 10px 12px; background: rgba(0,0,0,.35); border: 1px solid var(--line); border-radius: var(--radius-sm); | |
| font-size: 12px; overflow-x: auto; color: var(--text); } | |
| </style> | |
| <!-- Vendored locally (served by the backend at /static/vendor) so the UI needs no external CDN. --> | |
| <script src="/static/vendor/react.production.min.js"></script> | |
| <script src="/static/vendor/react-dom.production.min.js"></script> | |
| <script src="/static/vendor/htm.umd.js"></script> | |
| </head> | |
| <body> | |
| <div id="root"></div> | |
| <script> | |
| const { useState, useEffect, useRef } = React; | |
| const { createRoot } = ReactDOM; | |
| const html = htm.bind(React.createElement); | |
| const API = (p) => (window.SYNTHSCOPE_API || "") + p; | |
| const optLabel = (m) => `${m.name} — ${m.paper}` + (m.available ? "" : " (no checkpoint)"); | |
| const verdictAt = (p, thr) => (p >= thr ? "fake" : "real"); | |
| // --- threshold calibration helpers (client-side, from scored {p, gt} items) --- | |
| function balancedAccAt(scored, thr) { | |
| const reals = scored.filter(s => s.gt === "real"), fakes = scored.filter(s => s.gt === "fake"); | |
| const tnr = reals.length ? reals.filter(s => s.p < thr).length / reals.length : 0; | |
| const tpr = fakes.length ? fakes.filter(s => s.p >= thr).length / fakes.length : 0; | |
| return { acc: (tpr + tnr) / 2, tpr, tnr }; | |
| } | |
| function autoCalibrate(scored) { | |
| let best = 0.5, bestAcc = -1; | |
| for (let t = 0; t <= 1.0001; t += 0.01) { | |
| const a = balancedAccAt(scored, t).acc; | |
| if (a > bestAcc) { bestAcc = a; best = t; } | |
| } | |
| return Math.round(best * 100) / 100; | |
| } | |
| function targetFprThreshold(scored, fpr) { | |
| const reals = scored.filter(s => s.gt === "real").map(s => s.p).sort((a, b) => a - b); | |
| if (!reals.length) return null; | |
| // flag fake when p >= thr; FPR = fraction of reals >= thr. Pick the (1-fpr) quantile of reals. | |
| const idx = Math.min(reals.length - 1, Math.max(0, Math.ceil((1 - fpr) * reals.length) - 1)); | |
| return Math.round(reals[idx] * 100) / 100; | |
| } | |
| function batchStats(scored, thr) { | |
| if (!scored.length) return null; | |
| const reals = scored.filter(s => s.gt === "real"), fakes = scored.filter(s => s.gt === "fake"); | |
| const racc = reals.length ? reals.filter(s => s.p < thr).length / reals.length : null; | |
| const facc = fakes.length ? fakes.filter(s => s.p >= thr).length / fakes.length : null; | |
| const acc = scored.filter(s => verdictAt(s.p, thr) === s.gt).length / scored.length; | |
| return { n: scored.length, acc, racc, facc }; | |
| } | |
| // Short label + plain-language explanation of what a detector does to the image before scoring it. | |
| function InputNote({ m }) { | |
| if (!m || !m.input) return null; | |
| return html`<div class="inote"><span class="itag">${m.input.label}</span> ${m.input.explanation}</div>`; | |
| } | |
| function ModelSelect({ models, model, setModel, onChange }) { | |
| const imageModels = models.filter(m => m.modality === "image"); | |
| const videoModels = models.filter(m => m.modality === "video"); | |
| return html` | |
| <select value=${model} onChange=${e => { setModel(e.target.value); onChange && onChange(); }}> | |
| <optgroup label="Image detectors"> | |
| ${imageModels.map(m => html`<option key=${m.key} value=${m.key} disabled=${!m.available}>${optLabel(m)}</option>`)} | |
| </optgroup> | |
| <optgroup label="Video detectors"> | |
| ${videoModels.map(m => html`<option key=${m.key} value=${m.key} disabled=${!m.available}>${optLabel(m)}</option>`)} | |
| </optgroup> | |
| </select>`; | |
| } | |
| function ReproTable({ m }) { | |
| const rep = m && m.repro; | |
| const full = m && m.repro_full; | |
| if (!rep && !(full && full.length)) return null; | |
| return html`<div class="repro-wrap"> | |
| ${rep && html`<table class="repro"><tbody> | |
| <tr><td>Headline</td><td>${rep.benchmark}</td></tr> | |
| <tr><td>Paper</td><td>${rep.paper}</td></tr> | |
| <tr><td>Reproduced</td><td class=${"match-" + (rep.match || "")}>${rep.ours}</td></tr> | |
| </tbody></table>`} | |
| ${full && full.length > 0 && html`<details class="repro-details"> | |
| <summary>All ${full.length} reported benchmarks — paper vs SynthScope vs official</summary> | |
| <div class="repro-scroll"> | |
| <table class="repro-full"> | |
| <thead><tr><th>Benchmark</th><th>Paper</th><th>SynthScope-trained</th><th>Official weights</th><th>Status</th></tr></thead> | |
| <tbody> | |
| ${full.map((r, i) => html`<tr key=${i}> | |
| <td>${r.benchmark}</td> | |
| <td>${r.paper}</td> | |
| <td>${r.ours_synthscope || "—"}</td> | |
| <td>${r.ours_official || "—"}</td> | |
| <td><${StatusBadge} s=${r.status} /></td> | |
| </tr>`)} | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="repro-legend"> | |
| <b>reproduced</b> = SynthScope-trained checkpoint matches the paper · | |
| <b>official</b> = official weights loaded into the SynthScope class match (SynthScope-trained number shown where we trained from scratch) · | |
| <b>below</b> = gap remains · <b>reference</b> = paper reports no comparable number · <b>missing</b> = not yet evaluated. | |
| </div> | |
| </details>`} | |
| </div>`; | |
| } | |
| function b64ToFile(b64, name) { | |
| const bin = atob(b64), arr = new Uint8Array(bin.length); | |
| for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); | |
| return new File([arr], name, { type: "image/png" }); | |
| } | |
| // Detector families for the picker (keys not listed fall into "Other"). | |
| const DET_GROUPS = [ | |
| ["Low-level artifact detectors", ["npr", "safe", "aide", "spai", "pgc", "cnn"]], | |
| ["Semantic / foundation-model detectors", ["clip", "dinov2", "dinov3", "adapted_dino", "cure", "co_spy", "fat_former", "vib_net"]], | |
| ]; | |
| const DET_DEFAULT = ["safe", "npr", "clip", "cure"]; | |
| // Edit-mode starting points: the generator edits the photo itself, so the instruction must ask for a big change. | |
| const EDIT_PRESETS = [ | |
| ["Different place and light", "Keep the same kind of subject but place it in a completely different location, with different lighting and time of day, as a new photograph"], | |
| ["New camera angle", "Re-photograph this scene from a very different camera angle and distance"], | |
| ["Reimagine it", "Reimagine this as a completely new photograph with a different composition; keep only the type of subject"], | |
| ]; | |
| function pct(p) { return (100 * p).toFixed(1) + "%"; } | |
| // SOTA generator vs detector: pick/upload a real image -> a generator makes a new image ONCE -> any number of | |
| // detectors score the pair. Ticking another detector only scores it (no regeneration). | |
| const SETUP_URL = "https://github.com/SMSD75/SynthScope#readme"; | |
| const listNames = xs => xs.length < 2 ? (xs[0] || "") : xs.slice(0, -1).join(", ") + " and " + xs[xs.length - 1]; | |
| // Shown instead of "Test a generator" when no generator can run on this server (no GPU and no API key). | |
| function GeneratorOff({ demo }) { | |
| return html`<div class="card notice"> | |
| <h3>${demo ? "Switched off in this online demo" : "No image generator can run here"}</h3> | |
| <p>Testing a generator means creating new images. That needs a GPU, for the open models (FLUX.2 [klein], | |
| FLUX.1 Kontext, Qwen-Image), or your own API key, for the hosted ones (Nano Banana, GPT Image, Grok, FLUX via | |
| fal.ai). ${demo ? "This online demo has neither, so the tab is off here." : "This machine has neither."}</p> | |
| <p><b>Run SynthScope on your own computer to use it</b>: install it, add a GPU or an API key, and this tab | |
| generates an image from yours and scores it with every detector. See the <a href=${SETUP_URL} target="_blank" | |
| rel="noopener">setup guide</a>. Results we already ran for FLUX.2 [klein] and Qwen-Image are in the | |
| <i>Generator benchmarks</i> tab.</p> | |
| </div>`; | |
| } | |
| function SotaView({ models }) { | |
| const [gens, setGens] = useState([]); | |
| const [genDevice, setGenDevice] = useState(""); | |
| const [gen, setGen] = useState(""); | |
| const [prompt, setPrompt] = useState(""); | |
| const [mode, setMode] = useState("recreate"); // "recreate" = new image from a description, "edit" = edit the photo | |
| const [touched, setTouched] = useState(false); // the user edited the prompt, so do not overwrite it | |
| const [describing, setDescribing] = useState(false); | |
| const [realB64, setRealB64] = useState(""); | |
| const [realSrc, setRealSrc] = useState(null); | |
| const [genB64, setGenB64] = useState(""); | |
| const [genInfo, setGenInfo] = useState(null); | |
| const [picked, setPicked] = useState(null); // {key: bool}; null until the model list arrives | |
| const [scores, setScores] = useState({}); // key -> result for the CURRENT real/generated pair | |
| const [genBusy, setGenBusy] = useState(false); | |
| const [scoreBusy, setScoreBusy] = useState(false); | |
| const [elapsed, setElapsed] = useState(0); | |
| const [err, setErr] = useState(""); | |
| const asked = useRef(new Set()); // detector keys already requested for the current pair | |
| const pairId = useRef(0); // bumped whenever the pair changes; late responses are dropped | |
| const imgDetectors = (models || []).filter(m => m.modality === "image"); | |
| useEffect(() => { | |
| fetch(API("/api/sota/generators")).then(r => r.json()).then(d => { | |
| setGens(d.generators || []); | |
| setGenDevice(d.device || ""); | |
| const a = (d.generators || []).find(g => g.available); | |
| if (a) setGen(a.key); | |
| }).catch(e => setErr(String(e.message || e))); | |
| }, []); | |
| // Fill the prompt: a caption of the photo (re-create) or a preset edit instruction (edit), unless the user typed. | |
| useEffect(() => { | |
| if (touched) { setDescribing(false); return; } | |
| if (mode === "edit") { setDescribing(false); setPrompt(EDIT_PRESETS[2][1]); return; } | |
| if (!realB64) { setDescribing(false); setPrompt(""); return; } | |
| let live = true; | |
| setDescribing(true); | |
| const fd = new FormData(); fd.append("file", b64ToFile(realB64, "real.png")); | |
| fetch(API("/api/sota/describe"), { method: "POST", body: fd }).then(r => r.json()) | |
| .then(d => { if (live && d.prompt) setPrompt(d.prompt); }) | |
| .catch(() => {}).finally(() => { if (live) setDescribing(false); }); | |
| return () => { live = false; }; | |
| }, [realB64, mode, touched]); | |
| // A generator that supports only one mode forces that mode. | |
| useEffect(() => { | |
| const g = gens.find(x => x.key === gen); | |
| if (g && g.modes && !g.modes.includes(mode)) { setMode(g.modes[0]); setTouched(false); } | |
| }, [gen, gens]); | |
| useEffect(() => { | |
| if (picked !== null || !imgDetectors.length) return; | |
| const init = {}; | |
| DET_DEFAULT.forEach(k => { if (imgDetectors.some(m => m.key === k && m.available)) init[k] = true; }); | |
| setPicked(init); | |
| }, [models]); | |
| useEffect(() => { | |
| if (!genBusy) return; | |
| setElapsed(0); | |
| const t = setInterval(() => setElapsed(e => e + 1), 1000); | |
| return () => clearInterval(t); | |
| }, [genBusy]); | |
| // Score every ticked detector that has not been scored for this pair yet (no generation involved). | |
| useEffect(() => { | |
| if (!realB64 || !genB64 || !picked) return; | |
| const todo = Object.keys(picked).filter(k => picked[k] && !asked.current.has(k)); | |
| if (!todo.length) return; | |
| todo.forEach(k => asked.current.add(k)); | |
| const myPair = pairId.current; | |
| (async () => { | |
| setScoreBusy(true); setErr(""); | |
| try { | |
| const fd = new FormData(); | |
| fd.append("real", b64ToFile(realB64, "real.png")); fd.append("generated", b64ToFile(genB64, "gen.png")); | |
| fd.append("detectors", todo.join(",")); | |
| const r = await fetch(API("/api/sota/score"), { method: "POST", body: fd }); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| if (myPair !== pairId.current) return; // the image changed while scoring: these scores are stale | |
| setScores(s => { const n = { ...s }; d.results.forEach(x => { n[x.detector] = x; }); return n; }); | |
| } catch (e) { if (myPair === pairId.current) { todo.forEach(k => asked.current.delete(k)); setErr(String(e.message || e)); } } | |
| finally { setScoreBusy(false); } | |
| })(); | |
| }, [picked, genB64, realB64]); | |
| function resetPair() { pairId.current += 1; setGenB64(""); setGenInfo(null); setScores({}); asked.current = new Set(); } | |
| async function rollImage() { | |
| setErr(""); resetPair(); setTouched(false); | |
| try { | |
| const r = await fetch(API("/api/sota/real_image")); const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| setRealB64(d.image_b64); setRealSrc(d.source); | |
| } catch (e) { setErr(String(e.message || e)); } | |
| } | |
| function onUpload(f) { | |
| if (!f) return; | |
| const rd = new FileReader(); | |
| rd.onload = () => { resetPair(); setTouched(false); setRealB64(rd.result.split(",")[1]); setRealSrc(["upload", f.name]); }; | |
| rd.readAsDataURL(f); | |
| } | |
| async function generate() { | |
| setErr(""); resetPair(); setGenBusy(true); | |
| try { | |
| if (!realB64) throw new Error("Pick or upload a real image first."); | |
| if (!gen) throw new Error("Select a generator."); | |
| const fd = new FormData(); | |
| fd.append("file", b64ToFile(realB64, "real.png")); fd.append("generator", gen); fd.append("prompt", prompt); fd.append("mode", mode); | |
| const r = await fetch(API("/api/sota/generate"), { method: "POST", body: fd }); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| setGenInfo({ generator: d.generator, prompt: d.prompt, mode: d.mode, similarity: d.similarity, near_copy: d.near_copy, real_size: d.real_size, gen_size: d.gen_size }); | |
| setGenB64(d.generated_b64); | |
| } catch (e) { setErr(String(e.message || e)); } finally { setGenBusy(false); } | |
| } | |
| function setMany(keys, on) { | |
| const n = { ...(picked || {}) }; | |
| keys.forEach(k => { n[k] = on; }); | |
| setPicked(n); | |
| } | |
| const curGen = gens.find(g => g.key === gen); | |
| const listed = new Set(DET_GROUPS.flatMap(g => g[1])); | |
| const groups = [...DET_GROUPS.map(([label, keys]) => [label, imgDetectors.filter(m => keys.includes(m.key))]), | |
| ["Other", imgDetectors.filter(m => !listed.has(m.key))]].filter(g => g[1].length); | |
| const availKeys = imgDetectors.filter(m => m.available).map(m => m.key); | |
| const rows = Object.keys(picked || {}).filter(k => picked[k] && scores[k]).map(k => scores[k]); | |
| const waiting = Object.keys(picked || {}).filter(k => picked[k] && !scores[k]).length; | |
| const caught = rows.filter(r => r.caught).length; | |
| const mins = Math.floor(elapsed / 60), secs = String(elapsed % 60).padStart(2, "0"); | |
| return html` | |
| <div class="sota"> | |
| <p class="muted sota-intro">Take a real photo, let a current generator make a new image from it, then check which | |
| detectors flag the result. Generate once, then add or remove detectors freely - they score the same image.</p> | |
| <div class="sota-layout"> | |
| <div class="card sota-create"> | |
| <h2>Create</h2> | |
| <div class="step"><span class="stepno">1</span><b>Source image</b></div> | |
| <div class="srcbox"> | |
| ${realB64 ? html`<img src=${"data:image/png;base64," + realB64} />` | |
| : html`<div class="srcempty">No image yet</div>`} | |
| </div> | |
| <div class="row2"> | |
| <button class="sm" onClick=${rollImage}>🎲 Random real image</button> | |
| <label class="sm ghost">Upload…<input type="file" accept="image/*" style=${{display:"none"}} | |
| onChange=${e => onUpload(e.target.files[0])} /></label> | |
| </div> | |
| ${realSrc && html`<div class="meta">${realSrc[0] === "upload" ? "uploaded: " + realSrc[1] : realSrc.join(" / ")}</div>`} | |
| <div class="step"><span class="stepno">2</span><b>Generator</b></div> | |
| <select value=${gen} onChange=${e => setGen(e.target.value)}> | |
| ${gens.map(g => html`<option key=${g.key} value=${g.key} disabled=${!g.available}> | |
| ${g.name}${g.available ? "" | |
| : g.needs_gpu ? " (needs a GPU)" : g.local ? " (not set up here)" : " (needs an API key)"}</option>`)} | |
| </select> | |
| ${curGen && html`<div class="meta">${curGen.note}</div>`} | |
| <div class="step"><span class="stepno">3</span><b>How to create it</b></div> | |
| <div class="modes"> | |
| <button class=${"modebtn" + (mode === "recreate" ? " on" : "")} disabled=${!curGen || !curGen.modes.includes("recreate")} | |
| onClick=${() => { setMode("recreate"); setTouched(false); }}>Re-create from description</button> | |
| <button class=${"modebtn" + (mode === "edit" ? " on" : "")} disabled=${!curGen || !curGen.modes.includes("edit")} | |
| onClick=${() => { setMode("edit"); setTouched(false); }}>Edit this photo</button> | |
| </div> | |
| <div class="meta">${mode === "recreate" | |
| ? "The photo is described in words and the generator makes a brand-new image from that description alone, so every pixel is synthesized." | |
| : "The generator edits your photo itself, so the result keeps its layout and much of its content. Detectors then score an edited real photo."}</div> | |
| <label class="lab2">${mode === "recreate" ? (describing ? "Description (writing it…)" : "Description (edit it if you like)") : "Edit instruction"}</label> | |
| <textarea rows="4" value=${prompt} onInput=${e => { setPrompt(e.target.value); setTouched(true); }}></textarea> | |
| ${mode === "edit" && html`<div class="chips presets">${EDIT_PRESETS.map(([label, text]) => html`<button key=${label} class="chip" | |
| onClick=${() => { setPrompt(text); setTouched(true); }}>${label}</button>`)}</div>`} | |
| <button class="go" disabled=${genBusy || describing || !realB64 || !gen || !prompt.trim()} onClick=${generate}> | |
| ${genBusy ? `Generating… ${mins}:${secs}` : (genB64 ? "↻ Regenerate" : "Generate")}</button> | |
| ${genBusy && html`<div class="meta">Local open-weight models take 1–4 min (first run also downloads weights).</div>`} | |
| ${err && html`<div class="err">${err}</div>`} | |
| </div> | |
| <div class="sota-right"> | |
| <div class="card"> | |
| <h2>Result</h2> | |
| <div class="pair"> | |
| <figure> | |
| <div class="imgbox">${realB64 ? html`<img src=${"data:image/png;base64," + realB64} />` : html`<span>Real</span>`}</div> | |
| <figcaption>Real</figcaption> | |
| </figure> | |
| <figure> | |
| <div class="imgbox">${genB64 ? html`<img src=${"data:image/png;base64," + genB64} />` | |
| : html`<span>${genBusy ? "Generating…" : "Generated image appears here"}</span>`}</div> | |
| <figcaption>${genInfo ? "Generated · " + (curGen ? curGen.name : genInfo.generator) : "Generated"}</figcaption> | |
| </figure> | |
| </div> | |
| ${genInfo && genInfo.similarity != null && html`<div class=${"sim " + (genInfo.near_copy ? "warn" : "ok")}> | |
| Layout similarity to your photo: <b>${genInfo.similarity.toFixed(2)}</b>${" · "} | |
| ${genInfo.near_copy ? "very close: this looks like an edit of your photo, so the detectors are scoring an edited real photo rather than a new image." | |
| : "different enough to count as a newly generated image."}</div>`} | |
| ${genInfo && genInfo.gen_size && html`<div class="meta">Sizes: your photo ${genInfo.real_size[0]}×${genInfo.real_size[1]}, generated ${genInfo.gen_size[0]}×${genInfo.gen_size[1]}. Each detector scores each image at its own size, after the detector's own preprocessing.</div>`} | |
| ${genInfo && genInfo.prompt && html`<div class="meta">prompt used: ${genInfo.prompt}</div>`} | |
| </div> | |
| <div class="card"> | |
| <h2>Detectors <span class="muted">${Object.values(picked || {}).filter(Boolean).length} selected</span></h2> | |
| <div class="detbar"> | |
| <button class="chipbtn" onClick=${() => setMany(availKeys, true)}>Select all</button> | |
| <button class="chipbtn" onClick=${() => setMany(availKeys, false)}>Clear</button> | |
| </div> | |
| ${groups.map(([label, ms]) => html`<div class="detgroup" key=${label}> | |
| <div class="detlabel">${label}</div> | |
| <div class="chips"> | |
| ${ms.map(m => html`<button key=${m.key} disabled=${!m.available} | |
| title=${m.available ? m.paper + (m.input ? " · input: " + m.input.label : "") : "checkpoint not available on this server"} | |
| class=${"chip" + (picked && picked[m.key] ? " on" : "")} | |
| onClick=${() => setPicked({ ...(picked || {}), [m.key]: !(picked && picked[m.key]) })}>${m.name}</button>`)} | |
| </div> | |
| </div>`)} | |
| ${(rows.length > 0 || waiting > 0) && html`<div class="results"> | |
| <div class="sumline">${genB64 ? html`<b>${caught}</b> of <b>${rows.length}</b> detectors flag the generated image as fake${(scoreBusy || waiting) ? " · scoring…" : ""}` : ""}</div> | |
| ${rows.map(r => html`<div class=${"resrow " + (r.error ? "err2" : (r.caught ? "hit" : "miss"))} key=${r.detector}> | |
| <div class="rname">${r.name || r.detector}</div> | |
| ${r.error ? html`<div class="rerr">${r.error}</div>` : html` | |
| <div class="rbar"><div class="rfill" style=${{width: pct(r.gen_fake_prob)}}></div><div class="rthr"></div></div> | |
| <div class="rnum">${pct(r.gen_fake_prob)}<span class="muted"> real ${pct(r.real_fake_prob)}</span></div> | |
| <div class="rbadge">${r.caught ? "✓ caught" : "✗ missed"}</div>`} | |
| </div>`)} | |
| </div>`} | |
| ${!genB64 && html`<div class="meta">Scores appear here after you generate an image.</div>`} | |
| </div> | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| const DOCS_URL = "https://github.com/SMSD75/SynthScope/blob/main/docs/BENCHMARKS.md"; | |
| const STATUS_LABEL = { reproduced: "✓ Reproduced", official: "✓ Official weights match", below: "Below paper", | |
| reference: "Reference only", missing: "Not evaluated" }; | |
| function StatusBadge({ s }) { | |
| return html`<span class=${"sbadge status-" + (s || "")}>${STATUS_LABEL[s] || s || "—"}</span>`; | |
| } | |
| // Collapsed-by-default section for details most users do not need. | |
| function Adv({ title, open, children }) { | |
| return html`<details class="adv" open=${!!open}><summary>${title}</summary><div class="adv-body">${children}</div></details>`; | |
| } | |
| function ThresholdSlider({ threshold, setThreshold, children }) { | |
| return html`<div class="thr"> | |
| <div class="lab"><span>Decision threshold</span><span>P(fake) ≥ <b>${threshold.toFixed(2)}</b> → fake</span></div> | |
| <input type="range" min="0" max="1" step="0.01" value=${threshold} | |
| onInput=${e => setThreshold(parseFloat(e.target.value))} /> | |
| ${children} | |
| </div>`; | |
| } | |
| const LEGEND = html`<div class="repro-legend"> | |
| <b>Reproduced</b>: our own trained checkpoint matches the paper · | |
| <b>Official weights match</b>: the authors' released weights, run through SynthScope, match the paper · | |
| <b>Below paper</b>: a gap remains · <b>Reference only</b>: the paper reports no comparable number · | |
| <b>Not evaluated</b>: not run yet.</div>`; | |
| // Leaderboard: every method x benchmark (paper / SynthScope-trained / official weights), grouped by benchmark. | |
| function LeaderboardView() { | |
| const [groups, setGroups] = useState([]); | |
| const [summary, setSummary] = useState(null); | |
| const [sel, setSel] = useState(""); | |
| const [err, setErr] = useState(""); | |
| useEffect(() => { | |
| fetch(API("/api/leaderboard")).then(r => r.json()).then(d => { setGroups(d.groups || []); setSummary(d.summary || null); }) | |
| .catch(e => setErr(String(e.message || e))); | |
| }, []); | |
| const shown = sel ? groups.filter(g => g.benchmark === sel) : groups; | |
| return html`<div class="lead"> | |
| <div class="pagehead"> | |
| <p class="muted">How each detector scores on each benchmark: the number in its paper, our own re-trained | |
| checkpoint, and the authors' official weights. Methods that share a benchmark are ranked best-first.</p> | |
| <select class="inline" value=${sel} onChange=${e => setSel(e.target.value)}> | |
| <option value="">All benchmarks (${groups.length})</option> | |
| ${groups.map(g => html`<option key=${g.benchmark} value=${g.benchmark}>${g.benchmark} (${g.n_methods})</option>`)} | |
| </select> | |
| </div> | |
| ${summary && html`<div class="coverage"> | |
| <div><b>${summary.detectors_listed}</b> of ${summary.detectors_total} detectors · <b>${summary.benchmarks}</b> benchmarks · <b>${summary.results}</b> results</div> | |
| <div class="covchips"> | |
| <span class="sbadge status-reproduced">${summary.by_status.reproduced + summary.by_status.official} match the paper</span> | |
| <span class="sbadge status-below">${summary.by_status.below} below the paper</span> | |
| <span class="sbadge status-reference">${summary.by_status.reference} reference only</span> | |
| <span class="sbadge status-missing">${summary.by_status.missing} not evaluated yet</span> | |
| </div> | |
| ${summary.not_listed.length > 0 && html`<div class="meta">Not listed: ${summary.not_listed.join(", ")} (our own baselines; there is no paper to reproduce).</div>`} | |
| <div class="meta">Missing a number, or want to add one? <a href=${DOCS_URL} target="_blank" rel="noopener">How to set up and run the benchmarks</a>.</div> | |
| </div>`} | |
| ${err && html`<div class="err">${err}</div>`} | |
| ${shown.map((g, gi) => html`<details key=${g.benchmark} class="card lead-card" open=${!!sel || gi < 2}> | |
| <summary><span class="sumtitle">${g.benchmark}</span> | |
| <span class="pill">${g.n_methods} method${g.n_methods > 1 ? "s" : ""}</span></summary> | |
| <div class="repro-scroll"> | |
| <table class="repro-full"> | |
| <thead><tr><th>Method</th><th>Paper</th><th>SynthScope-trained</th><th>Official weights</th><th>Status</th></tr></thead> | |
| <tbody> | |
| ${g.rows.map((r, i) => html`<tr key=${i}> | |
| <td><b>${r.method}</b>${r.variant && html`<div class="variant">${r.variant}</div>`}</td> | |
| <td>${r.paper}</td> | |
| <td>${r.synthscope || "—"}</td> | |
| <td>${r.official || "—"}</td> | |
| <td><${StatusBadge} s=${r.status} /></td> | |
| </tr>`)} | |
| </tbody> | |
| </table> | |
| </div> | |
| </details>`)} | |
| <${Adv} title="What do the statuses mean?">${LEGEND}<//> | |
| </div>`; | |
| } | |
| // Generator benchmarks: how detectors hold up against today's SOTA generators. | |
| function TopGeneratorsView() { | |
| const [bm, setBm] = useState([]); | |
| const [ov, setOv] = useState(null); | |
| const [err, setErr] = useState(""); | |
| useEffect(() => { | |
| fetch(API("/api/generator_benchmarks")).then(r => r.json()).then(d => { setBm(d.benchmarks || []); setOv(d.overview || null); }) | |
| .catch(e => setErr(String(e.message || e))); | |
| }, []); | |
| return html`<div class="topgen"> | |
| <div class="pagehead"><p class="muted">How well each detector spots images from today's best generators. None of the | |
| detectors were trained on these generators, so this measures how well they generalize. Ranked by AUROC (higher is better).</p></div> | |
| ${err && html`<div class="err">${err}</div>`} | |
| ${!bm.length && !err && html`<div class="empty">No generator benchmarks yet.</div>`} | |
| ${ov && ov.generators.length > 0 && html`<div class="card"> | |
| <h2>Overview <span class="muted">AUROC, higher is better</span></h2> | |
| <div class="repro-scroll"><table class="repro-full ovtable"> | |
| <thead><tr><th>Detector</th>${ov.generators.map(g => html`<th key=${g}>${g}</th>`)}<th>Mean</th></tr></thead> | |
| <tbody>${ov.rows.map(r => html`<tr key=${r.name}><td><b>${r.name}</b></td> | |
| ${r.auroc.map((v, i) => html`<td key=${i} class="ovcell" style=${v == null ? {} : {background: "hsla(" + Math.max(0, Math.min(120, (v - 50) * 2.4)) + ",65%,45%,.35)"}}>${v == null ? "n/a" : v}</td>`)} | |
| <td class="ovcell"><b>${r.mean == null ? "n/a" : r.mean}</b></td></tr>`)}</tbody> | |
| </table></div> | |
| <div class="meta">Colour runs from red (about 50, chance level) to green (100). One row per detector, one column per generator.</div> | |
| </div>`} | |
| <${Adv} title="Add a generator to this page"> | |
| <div class="meta">Each column comes from one run of <code>benchmark/generator_bench.py</code>: it makes the images, scores every detector and writes the | |
| results this page shows. Local open-weight generators and hosted ones (with your API key) both work. | |
| <a href=${DOCS_URL} target="_blank" rel="noopener">Step-by-step guide</a>.</div> | |
| <pre class="cmd">python benchmark/generator_bench.py --generator flux2_klein --label "FLUX.2 [klein] 4B" \ | |
| --reuse benchmark/generator_runs/reference/manifest.json --out benchmark/generator_runs/flux2_klein_4b</pre> | |
| <//> | |
| ${bm.map(b => html`<div key=${b.key} class="card"> | |
| <h2>${b.generator} <span class="pill">${b.n_real + b.n_fake} images</span></h2> | |
| <div class="repro-scroll"> | |
| <table class="repro-full lb-gen"> | |
| <thead><tr><th>#</th><th>Detector</th><th>AUROC</th><th>AP</th><th>Accuracy</th><th>Fakes caught</th><th>Reals flagged</th></tr></thead> | |
| <tbody> | |
| ${b.rows.map((r, i) => html`<tr key=${i} class=${i < 3 ? "lb-top" : ""}> | |
| <td>${i === 0 ? "🥇" : i === 1 ? "🥈" : i === 2 ? "🥉" : (i + 1)}</td> | |
| <td><b>${r.name || r.detector}</b></td> | |
| <td class="lb-best">${r.auroc}</td> | |
| <td>${r.ap}</td><td>${r.acc}</td><td>${r.fake_detect_rate}%</td><td>${r.real_false_pos}%</td> | |
| </tr>`)} | |
| </tbody> | |
| </table> | |
| </div> | |
| <${Adv} title="Setup and notes"><div class="meta">${b.settings}. ${b.note}</div><//> | |
| </div>`)} | |
| </div>`; | |
| } | |
| const TABS = [["detect", "Detect"], ["sota", "Test a generator"], ["explore", "Browse datasets"], | |
| ["leaderboard", "Leaderboard"], ["topgen", "Generator benchmarks"]]; | |
| function App() { | |
| const [models, setModels] = useState([]); | |
| const [device, setDevice] = useState(""); | |
| const [model, setModel] = useState(""); | |
| const [tab, setTab] = useState("detect"); | |
| const [threshold, setThreshold] = useState(0.5); | |
| const [error, setError] = useState(""); | |
| const [hasData, setHasData] = useState(true); // hide "Browse datasets" when no dataset is installed (e.g. a public Space) | |
| const [hasGen, setHasGen] = useState(true); // "Test a generator" shows a notice when no generator can run here | |
| const [demo, setDemo] = useState(false); // hosted online demo (SYNTHSCOPE_DEMO=1, e.g. the Hugging Face Space) | |
| useEffect(() => { | |
| fetch(API("/api/datasets")).then(r => r.json()).then(d => setHasData((d.datasets || []).length > 0)).catch(() => {}); | |
| fetch(API("/api/sota/generators")).then(r => r.json()).then(d => { | |
| setHasGen((d.generators || []).some(g => g.available)); setDemo(!!d.demo); | |
| }).catch(() => {}); | |
| fetch(API("/api/models")).then(r => r.json()).then(d => { | |
| setModels(d.models || []); | |
| setDevice(d.device || ""); | |
| const firstAvail = (d.models || []).find(m => m.available); | |
| if (firstAvail) setModel(firstAvail.key); | |
| }).catch(e => setError("Could not reach backend: " + e)); | |
| }, []); | |
| const shared = { models, model, setModel, threshold, setThreshold }; | |
| return html` | |
| <div class="wrap"> | |
| <header class="top"> | |
| <div> | |
| <h1>SynthScope</h1> | |
| <p>Detect AI-generated images and videos, and see how today's detectors hold up against today's generators.</p> | |
| </div> | |
| ${device && html`<span class="pill" title="Where the detectors run">${device}</span>`} | |
| </header> | |
| <div class="tabs"> | |
| ${TABS.filter(([k]) => k !== "explore" || hasData).map(([k, label]) => html`<button key=${k} class=${"tab" + (tab === k ? " active" : "")} onClick=${() => setTab(k)}>${label}</button>`)} | |
| </div> | |
| ${error && html`<div class="err">${error}</div>`} | |
| ${tab === "detect" ? html`<${DetectView} ...${shared} />` | |
| : tab === "explore" ? html`<${ExploreView} ...${shared} />` | |
| : tab === "sota" ? (hasGen ? html`<${SotaView} models=${models} />` : html`<${GeneratorOff} demo=${demo} />`) | |
| : tab === "leaderboard" ? html`<${LeaderboardView} />` | |
| : html`<${TopGeneratorsView} />`} | |
| </div>`; | |
| } | |
| function DetectView({ models, model, setModel, threshold, setThreshold }) { | |
| const [file, setFile] = useState(null); | |
| const [previewUrl, setPreviewUrl] = useState(""); | |
| const [isVideo, setIsVideo] = useState(false); | |
| const [busy, setBusy] = useState(false); | |
| const [result, setResult] = useState(null); | |
| const [error, setError] = useState(""); | |
| const [drag, setDrag] = useState(false); | |
| const inputRef = useRef(); | |
| const selected = models.find(m => m.key === model); | |
| function onPick(f) { | |
| if (!f) return; | |
| setFile(f); setResult(null); setError(""); | |
| setIsVideo((f.type || "").startsWith("video") || /\.(mp4|mov|avi|mkv|webm|m4v)$/i.test(f.name)); | |
| if (previewUrl) URL.revokeObjectURL(previewUrl); | |
| setPreviewUrl(URL.createObjectURL(f)); | |
| } | |
| async function run() { | |
| if (!file || !model) return; | |
| setBusy(true); setResult(null); setError(""); | |
| try { | |
| const fd = new FormData(); fd.append("file", file); fd.append("model", model); | |
| const r = await fetch(API("/api/predict"), { method: "POST", body: fd }); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| setResult(d); | |
| } catch (e) { setError(String(e.message || e)); } | |
| finally { setBusy(false); } | |
| } | |
| return html` | |
| <div class="grid"> | |
| <div class="card"> | |
| <h2>Your file</h2> | |
| <div class=${"drop" + (drag ? " drag" : "")} | |
| onClick=${() => inputRef.current.click()} | |
| onDragOver=${e => { e.preventDefault(); setDrag(true); }} | |
| onDragLeave=${() => setDrag(false)} | |
| onDrop=${e => { e.preventDefault(); setDrag(false); onPick(e.dataTransfer.files[0]); }}> | |
| ${file ? html`<b>${file.name}</b> <span class="meta">(${(file.size/1048576).toFixed(2)} MB) · click to change</span>` | |
| : "Click or drop an image or video here"} | |
| </div> | |
| <input ref=${inputRef} type="file" accept="image/*,video/*" style=${{display:"none"}} | |
| onChange=${e => onPick(e.target.files[0])} /> | |
| ${previewUrl && html`<div class="preview"> | |
| ${isVideo ? html`<video src=${previewUrl} controls/>` : html`<img src=${previewUrl}/>`} | |
| </div>`} | |
| <label>Detector</label> | |
| <${ModelSelect} models=${models} model=${model} setModel=${setModel} onChange=${() => setResult(null)} /> | |
| ${selected && selected.modality === "image" && isVideo && html`<div class="meta">This image detector scores sampled video frames and averages them.</div>`} | |
| <button class="go" disabled=${!file || !model || busy} onClick=${run}> | |
| ${busy ? "Analyzing…" : "Analyze"} | |
| </button> | |
| ${error && html`<div class="err">${error}</div>`} | |
| ${selected && html`<${Adv} title="About this detector"> | |
| <div class="meta">${selected.paper}${selected.notes ? " · " + selected.notes : ""}</div> | |
| <${InputNote} m=${selected} /> | |
| <${ReproTable} m=${selected} /> | |
| <//>`} | |
| </div> | |
| <div class="card"> | |
| <h2>Result</h2> | |
| ${!result && !busy && html`<div class="empty">Upload a file and press Analyze.</div>`} | |
| ${busy && html`<div class="empty">Running ${selected ? selected.name : "the detector"}… the first run loads the model and may take a while.</div>`} | |
| ${result && html`<${Result} r=${result} threshold=${threshold} />`} | |
| ${result && html`<${Adv} title="Advanced: decision threshold"><${ThresholdSlider} threshold=${threshold} setThreshold=${setThreshold} /><//>`} | |
| </div> | |
| </div>`; | |
| } | |
| function ExploreView({ models, model, setModel, threshold, setThreshold }) { | |
| const [datasets, setDatasets] = useState([]); | |
| const [trainMap, setTrainMap] = useState({}); | |
| const [dataset, setDataset] = useState(""); | |
| const [split, setSplit] = useState(""); | |
| const [seed, setSeed] = useState(1); | |
| const [items, setItems] = useState([]); | |
| const [busy, setBusy] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [sel, setSel] = useState(null); | |
| const [preds, setPreds] = useState({}); // rel -> {fake_probability, ground_truth, ...} | |
| const [scoring, setScoring] = useState(false); | |
| const [fpr, setFpr] = useState(5); | |
| const [demo, setDemo] = useState(false); | |
| const [supported, setSupported] = useState(null); // {image: n, video: n} datasets a local install can fetch | |
| const batchN = 24; | |
| const curDs = datasets.find(d => d.key === dataset); | |
| const trainDsKey = (trainMap[model] || {}).dataset; | |
| useEffect(() => { | |
| fetch(API("/api/datasets")).then(r => r.json()).then(d => { | |
| setDatasets(d.datasets || []); setTrainMap(d.model_train || {}); | |
| setDemo(!!d.demo); setSupported(d.supported || null); | |
| if ((d.datasets || []).length) { | |
| const first = d.datasets[0]; | |
| setDataset(first.key); setSplit(first.splits[first.splits.length - 1] || "."); | |
| } | |
| }).catch(e => setError("Could not load datasets: " + e)); | |
| }, []); | |
| useEffect(() => { | |
| if (!curDs) return; | |
| const s = curDs.splits.includes(split) ? split : (curDs.splits[curDs.splits.length - 1] || "."); | |
| if (s !== split) setSplit(s); | |
| loadBatch(dataset, s, seed); | |
| }, [dataset]); | |
| async function loadBatch(dsKey, sp, sd) { | |
| if (!dsKey) return; | |
| setBusy(true); setError(""); setItems([]); setSel(null); setPreds({}); | |
| try { | |
| const r = await fetch(API(`/api/datasets/${dsKey}/sample?split=${encodeURIComponent(sp)}&n=${batchN}&seed=${sd}`)); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| setItems(d.items || []); | |
| } catch (e) { setError(String(e.message || e)); } | |
| finally { setBusy(false); } | |
| } | |
| function newBatch() { const sd = seed + 1; setSeed(sd); loadBatch(dataset, split, sd); } | |
| async function pickItem(it) { | |
| setSel(it.rel); | |
| if (!model || preds[it.rel]) return; | |
| try { | |
| const fd = new FormData(); | |
| fd.append("dataset", dataset); fd.append("rel", it.rel); fd.append("model", model); | |
| const r = await fetch(API("/api/predict_dataset"), { method: "POST", body: fd }); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| setPreds(p => ({ ...p, [it.rel]: d })); | |
| } catch (e) { setPreds(p => ({ ...p, [it.rel]: { error: String(e.message || e) } })); } | |
| } | |
| async function scoreAll() { | |
| if (!model || !items.length) return; | |
| setScoring(true); setError(""); | |
| try { | |
| const r = await fetch(API("/api/score_batch"), { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ dataset, model, rels: items.map(i => i.rel) }), | |
| }); | |
| const d = await r.json(); | |
| if (!r.ok) throw new Error(d.detail || ("HTTP " + r.status)); | |
| const m = {}; | |
| for (const it of d.items) m[it.rel] = it; | |
| setPreds(p => ({ ...p, ...m })); | |
| return d.items.filter(x => x.ground_truth && typeof x.fake_probability === "number") | |
| .map(x => ({ p: x.fake_probability, gt: x.ground_truth })); | |
| } catch (e) { setError(String(e.message || e)); return []; } | |
| finally { setScoring(false); } | |
| } | |
| const scored = items.map(i => preds[i.rel]).filter(x => x && typeof x.fake_probability === "number" && x.ground_truth) | |
| .map(x => ({ p: x.fake_probability, gt: x.ground_truth })); | |
| async function calibrate() { const s = scored.length ? scored : await scoreAll(); if (s.length) setThreshold(autoCalibrate(s)); } | |
| async function applyFpr() { | |
| const s = scored.length ? scored : await scoreAll(); | |
| const t = targetFprThreshold(s, fpr / 100); | |
| if (t != null) setThreshold(t); else setError("Need scored real videos for target-FPR."); | |
| } | |
| const mediaUrl = (it, thumb) => | |
| API(`/api/datasets/${dataset}/media?rel=${encodeURIComponent(it.rel)}${thumb ? "&thumb=1" : ""}`); | |
| const selItem = items.find(i => i.rel === sel); | |
| const selRes = sel ? preds[sel] : null; | |
| const stats = batchStats(scored, threshold); | |
| return html` | |
| <div class="explore"> | |
| ${demo && html`<div class="card notice slim"> | |
| <b>Demo subset.</b> To keep this online demo light it includes a few files per generator from | |
| ${" "}${listNames(datasets.map(d => d.name.split(" (")[0]))}. | |
| ${" "}Run SynthScope on your own computer and it downloads and browses every supported benchmark | |
| ${supported ? ` (${supported.image} image and ${supported.video} video datasets)` : ""}, each with one command; | |
| see the <a href=${SETUP_URL} target="_blank" rel="noopener">setup guide</a>. | |
| </div>`} | |
| <div class="card detbar2"> | |
| <div class="detsel"> | |
| <label>Detector</label> | |
| <${ModelSelect} models=${models} model=${model} setModel=${setModel} /> | |
| </div> | |
| ${trainDsKey && html`<div class="meta">Trained on${" "} | |
| <b>${(datasets.find(d=>d.key===trainDsKey)||{}).name || trainDsKey}</b>${" "} | |
| ${(trainMap[model]||{}).official ? html`<span class="badge">official weights</span>` : ""}</div>`} | |
| <${Adv} title="About this detector"><${InputNote} m=${models.find(m => m.key === model)} /><${ReproTable} m=${models.find(m => m.key === model)} /><//> | |
| </div> | |
| <div class="grid" style=${{gridTemplateColumns: "2fr 1fr", marginTop: 22}}> | |
| <div class="card"> | |
| <h2>Samples</h2> | |
| <div class="toolbar"> | |
| <div><label>Dataset</label> | |
| <select value=${dataset} onChange=${e => setDataset(e.target.value)}> | |
| ${datasets.map(d => html`<option key=${d.key} value=${d.key}> | |
| ${d.name}${d.key === trainDsKey ? " ★ training set" : ""} · ${d.modality}</option>`)} | |
| </select></div> | |
| <div style=${{maxWidth: 130}}><label>Split</label> | |
| <select value=${split} onChange=${e => { setSplit(e.target.value); loadBatch(dataset, e.target.value, seed); }}> | |
| ${(curDs ? curDs.splits : []).map(s => html`<option key=${s} value=${s}>${s === "." ? "(all)" : s}</option>`)} | |
| </select></div> | |
| <div style=${{maxWidth: 120, flex: "0 0 auto"}}><button class="sm" disabled=${busy} onClick=${newBatch}>↻ New batch</button></div> | |
| </div> | |
| ${error && html`<div class="err">${error}</div>`} | |
| ${busy && html`<div class="empty">Sampling…</div>`} | |
| <div class="thumbs"> | |
| ${items.map(it => { | |
| const pr = preds[it.rel]; | |
| const has = pr && typeof pr.fake_probability === "number"; | |
| const v = has ? verdictAt(pr.fake_probability, threshold) : null; | |
| const ok = has && v === it.label; | |
| return html`<div key=${it.rel} class=${"thumb" + (it.rel === sel ? " sel" : "")} onClick=${() => pickItem(it)} title=${it.name}> | |
| <img loading="lazy" src=${mediaUrl(it, true)} /> | |
| <span class=${"tag " + it.label}>${it.label}</span> | |
| ${it.modality === "video" && html`<span class="play">▶</span>`} | |
| ${has && html`<span class=${"pred " + (ok ? "ok" : "no")}>${(pr.fake_probability*100).toFixed(0)}% ${ok ? "✓" : "✗"}</span>`} | |
| </div>`; | |
| })} | |
| </div> | |
| ${stats && html`<div class="stat">Batch accuracy (${stats.n} scored, threshold ${threshold.toFixed(2)}): | |
| <b>${(stats.acc*100).toFixed(0)}%</b> | |
| ${stats.racc != null ? html` · real images ${(stats.racc*100).toFixed(0)}% right` : ""} | |
| ${stats.facc != null ? html` · fake images ${(stats.facc*100).toFixed(0)}% right` : ""}</div>`} | |
| <${Adv} title="Score the whole batch / tune the threshold"> | |
| <div class="calib"> | |
| <button disabled=${scoring || !model} onClick=${scoreAll}>${scoring ? "Scoring…" : "Score batch"}</button> | |
| <button disabled=${scoring || !model} onClick=${calibrate} title="Threshold that maximizes balanced accuracy on this labeled batch">Auto-calibrate</button> | |
| <button disabled=${scoring || !model} onClick=${applyFpr} title="Threshold that keeps real images wrongly flagged below this rate">Limit false alarms to</button> | |
| <input type="number" min="0" max="50" step="1" value=${fpr} onChange=${e => setFpr(parseFloat(e.target.value) || 0)} />% | |
| </div> | |
| <${ThresholdSlider} threshold=${threshold} setThreshold=${setThreshold} /> | |
| <//> | |
| </div> | |
| <div class="card"> | |
| <h2>Detector output</h2> | |
| ${!selItem && html`<div class="empty">Click a sample to run ${(models.find(m=>m.key===model)||{}).name || "the detector"} on it.</div>`} | |
| ${selItem && html`<div> | |
| <div class="preview"> | |
| ${selItem.modality === "video" ? html`<video src=${mediaUrl(selItem, false)} controls/>` : html`<img src=${mediaUrl(selItem, false)}/>`} | |
| </div> | |
| <div class="meta">${selItem.name} · ground truth: <b style=${{color: selItem.label==="fake"?"var(--fake)":"var(--real)"}}>${selItem.label}</b></div> | |
| ${selRes && selRes.error && html`<div class="err">${selRes.error}</div>`} | |
| ${selRes && !selRes.error && typeof selRes.fake_probability === "number" && html`<${Result} r=${selRes} threshold=${threshold} gt=${selItem.label} />`} | |
| </div>`} | |
| </div> | |
| </div> | |
| </div>`; | |
| } | |
| function Result({ r, threshold, gt }) { | |
| const fakePct = r.fake_probability * 100, realPct = (1 - r.fake_probability) * 100; | |
| const verdict = verdictAt(r.fake_probability, threshold); | |
| const correct = gt ? (verdict === gt) : null; | |
| return html` | |
| <div class=${"verdict " + verdict}>${verdict === "fake" ? "AI-GENERATED" : "REAL"}</div> | |
| <div style=${{textAlign:"center", color:"var(--muted)", fontSize:13}}> | |
| ${fakePct.toFixed(1)}% likely AI-generated | |
| ${correct != null ? html` · <b style=${{color: correct ? "var(--real)" : "var(--fake)"}}>${correct ? "correct" : "wrong"}</b>` : ""}</div> | |
| <div class="bar"> | |
| <span class="fake" style=${{width: fakePct + "%"}}>${fakePct >= 12 ? "fake " + fakePct.toFixed(0) + "%" : ""}</span> | |
| <span class="real" style=${{width: realPct + "%"}}>${realPct >= 12 ? "real " + realPct.toFixed(0) + "%" : ""}</span> | |
| <span class="thrline" style=${{left: (threshold*100) + "%"}}></span> | |
| </div> | |
| ${r.input_image && html`<div class="seen"> | |
| <div class="seen-title">What ${r.model_name || "the detector"} sees</div> | |
| <div class="imgcell seenbox"><img src=${r.input_image} alt="the detector's input" /></div> | |
| <div class="meta seen-cap"> | |
| ${r.input_note && html`<span class="itag">${r.input_note.label}</span>`} | |
| ${r.input_size && r.orig_size && html`<b>${r.input_size[0]}×${r.input_size[1]} px</b>${(r.input_size[0] === r.orig_size[0] && r.input_size[1] === r.orig_size[1]) ? " (your image's own size)" : " (from your " + r.orig_size[0] + "×" + r.orig_size[1] + " image)"}. `} | |
| ${r.input_note && r.input_note.explanation} | |
| </div> | |
| </div>`} | |
| <div class="meta" style=${{textAlign:"center"}}>${[r.media_type ? r.media_type + (r.num_frames ? " · " + r.num_frames + " frames" : "") : "", r.elapsed_ms ? r.elapsed_ms + " ms" : ""].filter(Boolean).join(" · ")}</div> | |
| ${r.per_frame && r.per_frame.length > 1 && html`<div class="frames"> | |
| <div class="meta">Per-frame P(fake) — ${r.per_frame.length} frames sampled (dashed line = 50% threshold):</div> | |
| <div class="spark"> | |
| <span class="thr50"></span> | |
| ${r.per_frame.map((v,i) => html`<i key=${i} title=${"frame " + (i+1) + ": " + (v*100).toFixed(1) + "%"} | |
| style=${{height: Math.max(3, v*100) + "%", background: v >= 0.5 ? "var(--fake)" : "var(--accent)"}}/>`)} | |
| </div> | |
| <div class="frame-stats"> | |
| <span>min <b>${(Math.min(...r.per_frame)*100).toFixed(0)}%</b></span> | |
| <span>mean <b>${(r.per_frame.reduce((a,b)=>a+b,0)/r.per_frame.length*100).toFixed(0)}%</b></span> | |
| <span>max <b>${(Math.max(...r.per_frame)*100).toFixed(0)}%</b></span> | |
| </div> | |
| </div>`} | |
| ${r.interpret_map && html`<${Heatmap} r=${r} />`}`; | |
| } | |
| // What each heatmap shows. "evidence" maps point at regions that look generated; "signal" maps show the low-level | |
| // pattern the detector reads, which is not by itself a verdict. | |
| const HEAT_INFO = { | |
| spai_patch: ["evidence", "Spectral evidence (SPAI)", "Each patch is scored on its frequency spectrum. Red patches look most like AI-generated content."], | |
| pgc_peak: ["evidence", "Peak-guided evidence (PGC)", "Highlights the image regions whose fine residual pattern contributes most to the 'fake' score."], | |
| safe_hf: ["signal", "High-frequency detail (SAFE)", "Shows the fine texture band SAFE inspects. Bright areas have stronger fine-grained structure; this is the signal it reads, not a verdict."], | |
| npr_residual: ["signal", "Neighbouring-pixel residual (NPR)", "Shows how each pixel differs from its neighbours, the up-sampling trace NPR looks for. Brighter means a stronger trace."], | |
| aide_dct: ["signal", "Frequency-patch energy (AIDE)", "Shows where the image has the most high-frequency (DCT) energy; AIDE picks its patches from these areas."], | |
| }; | |
| function Heatmap({ r }) { | |
| const [kind, title, expl] = HEAT_INFO[r.interpret_kind] || ["signal", "Artifact map", "Highlights image regions the detector relies on."]; | |
| return html`<div class="interp"> | |
| <div class="interp-title">Where the detector looked <span class="itag">${title}</span></div> | |
| <div class="meta">${expl}</div> | |
| <div class="legend"> | |
| <span>${kind === "evidence" ? "weaker evidence" : "weaker signal"}</span><i class="legbar"></i><span>${kind === "evidence" ? "stronger evidence of AI generation" : "stronger signal"}</span> | |
| </div> | |
| <div class="interp-row"> | |
| ${r.interpret_overlay && html`<figure class="interp-cell"><div class="imgcell"><img class="interp-map" src=${r.interpret_overlay} alt="heatmap on your image"/></div><figcaption>On your image</figcaption></figure>`} | |
| <figure class="interp-cell"><div class="imgcell"><img class="interp-map" src=${r.interpret_map} alt="heatmap"/></div><figcaption>Heatmap only</figcaption></figure> | |
| </div> | |
| <div class="meta">${kind === "signal" ? "A strong signal is not proof of AI generation: real photos can have it too." : "Evidence maps are an aid to interpretation; the verdict above is the detector's actual score."}</div> | |
| </div>`; | |
| } | |
| createRoot(document.getElementById("root")).render(html`<${App}/>`); | |
| </script> | |
| </body> | |
| </html> | |