Spaces:
Runtime error
Runtime error
Commit ·
879d39c
0
Parent(s):
Clean deployment for Hugging Face
Browse files- .DS_Store +0 -0
- .gitattributes +1 -0
- .gitignore +210 -0
- Dockerfile +10 -0
- LICENSE +21 -0
- README.md +253 -0
- app.py +269 -0
- datasets/.DS_Store +0 -0
- extension/background.js +114 -0
- extension/content.js +404 -0
- extension/manifest.json +29 -0
- extension/sidepanel.html +635 -0
- extension/sidepanel.js +268 -0
- models/.DS_Store +0 -0
- models/convnext_forensic_head.pth +3 -0
- models/convnext_training_log.csv +6 -0
- models/openclip_forensic_head.pth +3 -0
- models/training_log.csv +11 -0
- requirements.txt +64 -0
- test.py +144 -0
- train_convnext.py +168 -0
- train_openclip.py +178 -0
- visualise.py +59 -0
.DS_Store
ADDED
|
Binary file (10.2 kB). View file
|
|
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[codz]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
# Usually these files are written by a python script from a template
|
| 31 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 32 |
+
*.manifest
|
| 33 |
+
*.spec
|
| 34 |
+
|
| 35 |
+
# Installer logs
|
| 36 |
+
pip-log.txt
|
| 37 |
+
pip-delete-this-directory.txt
|
| 38 |
+
|
| 39 |
+
# Unit test / coverage reports
|
| 40 |
+
htmlcov/
|
| 41 |
+
.tox/
|
| 42 |
+
.nox/
|
| 43 |
+
.coverage
|
| 44 |
+
.coverage.*
|
| 45 |
+
.cache
|
| 46 |
+
nosetests.xml
|
| 47 |
+
coverage.xml
|
| 48 |
+
*.cover
|
| 49 |
+
*.py.cover
|
| 50 |
+
.hypothesis/
|
| 51 |
+
.pytest_cache/
|
| 52 |
+
cover/
|
| 53 |
+
|
| 54 |
+
# Translations
|
| 55 |
+
*.mo
|
| 56 |
+
*.pot
|
| 57 |
+
|
| 58 |
+
# Django stuff:
|
| 59 |
+
*.log
|
| 60 |
+
local_settings.py
|
| 61 |
+
db.sqlite3
|
| 62 |
+
db.sqlite3-journal
|
| 63 |
+
|
| 64 |
+
# Flask stuff:
|
| 65 |
+
instance/
|
| 66 |
+
.webassets-cache
|
| 67 |
+
|
| 68 |
+
# Scrapy stuff:
|
| 69 |
+
.scrapy
|
| 70 |
+
|
| 71 |
+
# Sphinx documentation
|
| 72 |
+
docs/_build/
|
| 73 |
+
|
| 74 |
+
# PyBuilder
|
| 75 |
+
.pybuilder/
|
| 76 |
+
target/
|
| 77 |
+
|
| 78 |
+
# Jupyter Notebook
|
| 79 |
+
.ipynb_checkpoints
|
| 80 |
+
|
| 81 |
+
# IPython
|
| 82 |
+
profile_default/
|
| 83 |
+
ipython_config.py
|
| 84 |
+
|
| 85 |
+
# pyenv
|
| 86 |
+
# For a library or package, you might want to ignore these files since the code is
|
| 87 |
+
# intended to run in multiple environments; otherwise, check them in:
|
| 88 |
+
# .python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# UV
|
| 98 |
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
| 99 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 100 |
+
# commonly ignored for libraries.
|
| 101 |
+
#uv.lock
|
| 102 |
+
|
| 103 |
+
# poetry
|
| 104 |
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
| 105 |
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
| 106 |
+
# commonly ignored for libraries.
|
| 107 |
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
| 108 |
+
#poetry.lock
|
| 109 |
+
#poetry.toml
|
| 110 |
+
|
| 111 |
+
# pdm
|
| 112 |
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 113 |
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 114 |
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 115 |
+
#pdm.lock
|
| 116 |
+
#pdm.toml
|
| 117 |
+
.pdm-python
|
| 118 |
+
.pdm-build/
|
| 119 |
+
|
| 120 |
+
# pixi
|
| 121 |
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
| 122 |
+
#pixi.lock
|
| 123 |
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
| 124 |
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
| 125 |
+
.pixi
|
| 126 |
+
|
| 127 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
| 128 |
+
__pypackages__/
|
| 129 |
+
|
| 130 |
+
# Celery stuff
|
| 131 |
+
celerybeat-schedule
|
| 132 |
+
celerybeat.pid
|
| 133 |
+
|
| 134 |
+
# SageMath parsed files
|
| 135 |
+
*.sage.py
|
| 136 |
+
|
| 137 |
+
# Environments
|
| 138 |
+
.env
|
| 139 |
+
.envrc
|
| 140 |
+
.venv
|
| 141 |
+
env/
|
| 142 |
+
venv/
|
| 143 |
+
ENV/
|
| 144 |
+
env.bak/
|
| 145 |
+
venv.bak/
|
| 146 |
+
|
| 147 |
+
# Spyder project settings
|
| 148 |
+
.spyderproject
|
| 149 |
+
.spyproject
|
| 150 |
+
|
| 151 |
+
# Rope project settings
|
| 152 |
+
.ropeproject
|
| 153 |
+
|
| 154 |
+
# mkdocs documentation
|
| 155 |
+
/site
|
| 156 |
+
|
| 157 |
+
# mypy
|
| 158 |
+
.mypy_cache/
|
| 159 |
+
.dmypy.json
|
| 160 |
+
dmypy.json
|
| 161 |
+
|
| 162 |
+
# Pyre type checker
|
| 163 |
+
.pyre/
|
| 164 |
+
|
| 165 |
+
# pytype static type analyzer
|
| 166 |
+
.pytype/
|
| 167 |
+
|
| 168 |
+
# Cython debug symbols
|
| 169 |
+
cython_debug/
|
| 170 |
+
|
| 171 |
+
# PyCharm
|
| 172 |
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
| 173 |
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 174 |
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 175 |
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 176 |
+
#.idea/
|
| 177 |
+
|
| 178 |
+
# Abstra
|
| 179 |
+
# Abstra is an AI-powered process automation framework.
|
| 180 |
+
# Ignore directories containing user credentials, local state, and settings.
|
| 181 |
+
# Learn more at https://abstra.io/docs
|
| 182 |
+
.abstra/
|
| 183 |
+
|
| 184 |
+
# Visual Studio Code
|
| 185 |
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
| 186 |
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
| 187 |
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
| 188 |
+
# you could uncomment the following to ignore the entire vscode folder
|
| 189 |
+
# .vscode/
|
| 190 |
+
|
| 191 |
+
# Ruff stuff:
|
| 192 |
+
.ruff_cache/
|
| 193 |
+
|
| 194 |
+
# PyPI configuration file
|
| 195 |
+
.pypirc
|
| 196 |
+
|
| 197 |
+
# Cursor
|
| 198 |
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
| 199 |
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
| 200 |
+
# refer to https://docs.cursor.com/context/ignore-files
|
| 201 |
+
.cursorignore
|
| 202 |
+
.cursorindexingignore
|
| 203 |
+
|
| 204 |
+
# Marimo
|
| 205 |
+
marimo/_static/
|
| 206 |
+
marimo/_lsp/
|
| 207 |
+
__marimo__/
|
| 208 |
+
|
| 209 |
+
datasets/CIFAKE/
|
| 210 |
+
.idea/
|
Dockerfile
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt /app/
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 7860
|
| 7 |
+
RUN useradd -m -u 1000 user
|
| 8 |
+
RUN chown -R user:user /app
|
| 9 |
+
USER user
|
| 10 |
+
CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app", "--timeout", "120"]
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Rishit Baitule
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🧠 AI Detector — Real-Time Deepfake Detection Extension
|
| 2 |
+
|
| 3 |
+
<div align="center">
|
| 4 |
+
|
| 5 |
+
**Bringing trust back to the internet — one image at a time.**
|
| 6 |
+
|
| 7 |
+
[](https://github.com/How2Invade/extension-deepfake)
|
| 8 |
+
[](https://github.com/How2Invade/extension-deepfake)
|
| 9 |
+
[](https://flask.palletsprojects.com/)
|
| 10 |
+
[](https://chrome.google.com/)
|
| 11 |
+
[](https://pytorch.org/)
|
| 12 |
+
[](https://python.org/)
|
| 13 |
+
|
| 14 |
+
[Report Bug](https://github.com/How2Invade/extension-deepfake/issues) • [Request Feature](https://github.com/How2Invade/extension-deepfake/issues) • [View Demo](#-output)
|
| 15 |
+
|
| 16 |
+
**Built for Prakalp 4.0**
|
| 17 |
+
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## ✨ What is AI Detector?
|
| 23 |
+
|
| 24 |
+
With the explosion of generative AI tools like **Stable Diffusion**, **Midjourney**, and **DALL·E**, distinguishing real images from synthetic ones has become nearly impossible for the human eye.
|
| 25 |
+
|
| 26 |
+
**AI Detector** is a real-time deepfake detection Chrome extension that scans every image on any webpage and classifies it as:
|
| 27 |
+
|
| 28 |
+
- 🧠 **AI Generated** — flagged with a red border and confidence score
|
| 29 |
+
- 📷 **Real** — confirmed with a green border and confidence score
|
| 30 |
+
|
| 31 |
+
It doesn't just stop there. It calculates a **full page-level risk score** so you can instantly understand how much synthetic content you're being served — on any website, at any time.
|
| 32 |
+
|
| 33 |
+
> In a world where AI can generate anything — this system helps you understand what is real.
|
| 34 |
+
|
| 35 |
+
---
|
| 36 |
+
|
| 37 |
+
## 🚀 Features
|
| 38 |
+
|
| 39 |
+
### 🔍 Real-Time Image Scanning
|
| 40 |
+
- Detects all visible images on any webpage
|
| 41 |
+
- Works on Google Images, blogs, news sites, social media — anywhere
|
| 42 |
+
- Ignores hidden or non-visible DOM elements for accurate results
|
| 43 |
+
|
| 44 |
+
### 🎯 AI vs Real Classification
|
| 45 |
+
- Powered by **OpenCLIP (ViT-L-14)** deep vision model
|
| 46 |
+
- Transfer learning with a custom binary classification head
|
| 47 |
+
- Outputs a clean probability score per image
|
| 48 |
+
|
| 49 |
+
### 🎨 Visual Highlighting
|
| 50 |
+
- 🔴 **Red border** → AI Generated
|
| 51 |
+
- 🟢 **Green border** → Real
|
| 52 |
+
- Hover over any image to see its label + confidence score
|
| 53 |
+
|
| 54 |
+
### 📊 Page Risk Analysis
|
| 55 |
+
Calculates an overall AI content percentage for the entire page:
|
| 56 |
+
|
| 57 |
+
| Risk Level | Range | Meaning |
|
| 58 |
+
|------------|-------|---------|
|
| 59 |
+
| 🟢 Low Risk | 0–30% | Mostly real content |
|
| 60 |
+
| 🟡 Moderate Risk | 30–70% | Mixed content |
|
| 61 |
+
| 🔴 High Risk | 70–100% | Mostly AI-generated |
|
| 62 |
+
|
| 63 |
+
### ⚡ Clean UI/UX
|
| 64 |
+
- Minimal, Apple-inspired design
|
| 65 |
+
- Fast and non-intrusive
|
| 66 |
+
- Confidence scores displayed inline
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## ⚙️ How It Works
|
| 71 |
+
|
| 72 |
+
### Step 1 — Image Extraction
|
| 73 |
+
The extension scans the webpage DOM and collects all visible `<img>` elements, extracting their `src` URLs for backend processing.
|
| 74 |
+
|
| 75 |
+
### Step 2 — URL-Based Backend Processing
|
| 76 |
+
Image URLs are sent directly to the Flask backend — no screenshots, no compression artifacts. This ensures maximum accuracy and faster processing.
|
| 77 |
+
|
| 78 |
+
### Step 3 — Feature Extraction via OpenCLIP
|
| 79 |
+
The backend uses a frozen **OpenCLIP (ViT-L-14)** backbone pretrained on `datacomp_xl_s13b_b90k` to convert each image into rich deep feature embeddings.
|
| 80 |
+
|
| 81 |
+
### Step 4 — Custom Classification Head
|
| 82 |
+
A lightweight neural network processes those embeddings:
|
| 83 |
+
|
| 84 |
+
```
|
| 85 |
+
Linear → ReLU → Dropout → Linear → Sigmoid
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
Output: A probability score indicating how likely the image is AI-generated.
|
| 89 |
+
|
| 90 |
+
### Step 5 — UI Rendering
|
| 91 |
+
Results are pushed back to the extension, which:
|
| 92 |
+
- Highlights each image directly on the page
|
| 93 |
+
- Overlays confidence labels on hover
|
| 94 |
+
- Updates the popup with page-level risk analysis
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
## 🧪 Model Details
|
| 99 |
+
|
| 100 |
+
| Property | Value |
|
| 101 |
+
|----------|-------|
|
| 102 |
+
| **Backbone** | OpenCLIP (ViT-L-14) |
|
| 103 |
+
| **Pretrained On** | datacomp_xl_s13b_b90k |
|
| 104 |
+
| **Approach** | Transfer Learning |
|
| 105 |
+
| **Encoder** | Frozen |
|
| 106 |
+
| **Head** | Custom Binary Classifier |
|
| 107 |
+
| **Output** | AI / Real + Probability |
|
| 108 |
+
|
| 109 |
+
### 📂 Training Dataset
|
| 110 |
+
|
| 111 |
+
**AI Images:** Stable Diffusion, Midjourney, DALL·E, Flux
|
| 112 |
+
|
| 113 |
+
**Real Images:** Unsplash dataset
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## 🏗 Project Structure
|
| 118 |
+
|
| 119 |
+
```
|
| 120 |
+
extension-deepfake/
|
| 121 |
+
│
|
| 122 |
+
├── app.py # Flask backend — model inference & API
|
| 123 |
+
├── OpenCLIP_forensic_head.pth # Trained model weights
|
| 124 |
+
├── train.py # Training script (reference only)
|
| 125 |
+
│
|
| 126 |
+
└── extension/
|
| 127 |
+
├── manifest.json # Chrome extension configuration
|
| 128 |
+
├── popup.html # Extension UI layout
|
| 129 |
+
├── popup.js # Button logic & UI state updates
|
| 130 |
+
└── content.js # DOM scanning & image highlighting
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## ⚙️ Setup & Installation
|
| 136 |
+
|
| 137 |
+
### Prerequisites
|
| 138 |
+
|
| 139 |
+
- Python 3.8+
|
| 140 |
+
- pip
|
| 141 |
+
- Google Chrome browser
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
### 1. Clone the Repository
|
| 146 |
+
|
| 147 |
+
```bash
|
| 148 |
+
git clone https://github.com/How2Invade/extension-deepfake.git
|
| 149 |
+
cd extension-deepfake
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
---
|
| 153 |
+
|
| 154 |
+
### 2. Install Backend Dependencies
|
| 155 |
+
|
| 156 |
+
```bash
|
| 157 |
+
pip install torch torchvision flask pillow open_clip_torch
|
| 158 |
+
```
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
### 3. Run the Flask Backend
|
| 163 |
+
|
| 164 |
+
```bash
|
| 165 |
+
python app.py
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
The server will start at:
|
| 169 |
+
|
| 170 |
+
```
|
| 171 |
+
http://127.0.0.1:5000
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
> Keep this terminal running while using the extension.
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
### 4. Load the Chrome Extension
|
| 179 |
+
|
| 180 |
+
1. Open Chrome and navigate to `chrome://extensions/`
|
| 181 |
+
2. Toggle **Developer Mode** (top right)
|
| 182 |
+
3. Click **Load Unpacked**
|
| 183 |
+
4. Select the `extension/` folder from the cloned repo
|
| 184 |
+
|
| 185 |
+
---
|
| 186 |
+
|
| 187 |
+
### 5. Start Detecting
|
| 188 |
+
|
| 189 |
+
1. Open any webpage (try Google Images)
|
| 190 |
+
2. Click the **AI Detector** extension icon
|
| 191 |
+
3. Hit **Scan Images**
|
| 192 |
+
|
| 193 |
+
Every image on the page will be analyzed and highlighted instantly.
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## 🎯 Output
|
| 198 |
+
|
| 199 |
+
Each scanned image receives:
|
| 200 |
+
- A **colored border** (Red = AI, Green = Real)
|
| 201 |
+
- A **confidence label** on hover (e.g., `AI — 78%` or `Real — 64%`)
|
| 202 |
+
|
| 203 |
+
The popup displays:
|
| 204 |
+
- Overall **risk percentage**
|
| 205 |
+
- Page-level **verdict** (Low / Moderate / High Risk)
|
| 206 |
+
|
| 207 |
+
---
|
| 208 |
+
|
| 209 |
+
## 🔮 Future Scope
|
| 210 |
+
|
| 211 |
+
- 🎥 **Video deepfake detection** — frame-by-frame analysis
|
| 212 |
+
- 🎙️ **Audio deepfake detection** — voice synthesis identification
|
| 213 |
+
- ☁️ **Cloud deployment** — remove the local backend requirement
|
| 214 |
+
- 📊 **Advanced analytics dashboard** — historical scan data & trends
|
| 215 |
+
- ⚡ **Batch inference optimization** — faster multi-image processing
|
| 216 |
+
|
| 217 |
+
---
|
| 218 |
+
|
| 219 |
+
## ⚠️ Limitations
|
| 220 |
+
|
| 221 |
+
- Accuracy depends on training data coverage
|
| 222 |
+
- Some false positives may occur on heavily stylized images
|
| 223 |
+
- Backend must be running locally for the extension to work
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## 🏆 Impact
|
| 228 |
+
|
| 229 |
+
AI Detector directly addresses one of the most pressing issues of the AI era:
|
| 230 |
+
|
| 231 |
+
- ✅ Detecting fake and synthetic content at scale
|
| 232 |
+
- ✅ Reducing the spread of AI-powered misinformation
|
| 233 |
+
- ✅ Empowering users to verify what they see online
|
| 234 |
+
- ✅ Assisting journalists, researchers, and media professionals
|
| 235 |
+
|
| 236 |
+
---
|
| 237 |
+
|
| 238 |
+
## 🙏 Acknowledgments
|
| 239 |
+
|
| 240 |
+
- [OpenCLIP](https://github.com/mlfoundations/open_clip) for the OpenCLIP vision model
|
| 241 |
+
- [PyTorch](https://pytorch.org/) ecosystem for model training and inference
|
| 242 |
+
- [Chrome Extensions API](https://developer.chrome.com/docs/extensions/) for browser integration
|
| 243 |
+
- [Unsplash](https://unsplash.com/) for real image training data
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
<div align="center">
|
| 248 |
+
|
| 249 |
+
### 💡 Built to bring trust in an AI-generated world
|
| 250 |
+
|
| 251 |
+
[⭐ Star on GitHub](https://github.com/How2Invade/extension-deepfake) • [🐛 Report an Issue](https://github.com/How2Invade/extension-deepfake/issues)
|
| 252 |
+
|
| 253 |
+
</div>
|
app.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, request, jsonify
|
| 2 |
+
from flask_cors import CORS
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import open_clip
|
| 6 |
+
from PIL import Image, ImageFilter
|
| 7 |
+
from torchvision import transforms, models
|
| 8 |
+
from io import BytesIO
|
| 9 |
+
import requests
|
| 10 |
+
import base64
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
app = Flask(__name__)
|
| 14 |
+
CORS(app)
|
| 15 |
+
|
| 16 |
+
DEVICE = torch.device("mps") if torch.backends.mps.is_available() else torch.device("cpu")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ForensicHead(nn.Module):
|
| 20 |
+
def __init__(self, input_dim=768):
|
| 21 |
+
super().__init__()
|
| 22 |
+
self.net = nn.Sequential(
|
| 23 |
+
nn.Linear(input_dim, 512),
|
| 24 |
+
nn.ReLU(),
|
| 25 |
+
nn.Dropout(0.3),
|
| 26 |
+
nn.Linear(512, 1),
|
| 27 |
+
nn.Sigmoid()
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
def forward(self, x):
|
| 31 |
+
return self.net(x)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
print("Loading Models...")
|
| 35 |
+
|
| 36 |
+
model, _, preprocess = open_clip.create_model_and_transforms(
|
| 37 |
+
"ViT-L-14",
|
| 38 |
+
pretrained="datacomp_xl_s13b_b90k"
|
| 39 |
+
)
|
| 40 |
+
model = model.to(DEVICE)
|
| 41 |
+
model.eval()
|
| 42 |
+
|
| 43 |
+
tokenizer = open_clip.get_tokenizer("ViT-L-14")
|
| 44 |
+
|
| 45 |
+
AI_FLAWS = [
|
| 46 |
+
"plastic skin, overly smooth textures, and lack of realistic pores",
|
| 47 |
+
"distorted anatomical shapes like strange hands, limbs, or face",
|
| 48 |
+
"inconsistent lighting, impossible shadows, or unnatural highlights",
|
| 49 |
+
"garbled, blurred, or nonsensical background details and text",
|
| 50 |
+
"asymmetrical facial features or floating elements",
|
| 51 |
+
"blending errors where subjects melt unnaturally into the background"
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
REAL_TRAITS = [
|
| 55 |
+
"natural texture with visible realistic imperfections",
|
| 56 |
+
"physically consistent lighting, shadows, and reflections",
|
| 57 |
+
"natural anatomical proportions and distinct physical boundaries",
|
| 58 |
+
"sharp, coherent background elements and depth of field",
|
| 59 |
+
"authentic noise and realistic color balance"
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
print("Encoding explainability vectors...")
|
| 63 |
+
ai_tokens = tokenizer(AI_FLAWS).to(DEVICE)
|
| 64 |
+
real_tokens = tokenizer(REAL_TRAITS).to(DEVICE)
|
| 65 |
+
|
| 66 |
+
with torch.no_grad():
|
| 67 |
+
ai_text_features = model.encode_text(ai_tokens)
|
| 68 |
+
ai_text_features /= ai_text_features.norm(dim=-1, keepdim=True)
|
| 69 |
+
|
| 70 |
+
real_text_features = model.encode_text(real_tokens)
|
| 71 |
+
real_text_features /= real_text_features.norm(dim=-1, keepdim=True)
|
| 72 |
+
|
| 73 |
+
head = ForensicHead(input_dim=768)
|
| 74 |
+
head.load_state_dict(torch.load("models/openclip_forensic_head.pth", map_location=DEVICE))
|
| 75 |
+
head = head.to(DEVICE)
|
| 76 |
+
head.eval()
|
| 77 |
+
|
| 78 |
+
cn_backbone = models.convnext_base(weights=None)
|
| 79 |
+
cn_backbone.to(DEVICE)
|
| 80 |
+
cn_backbone.eval()
|
| 81 |
+
|
| 82 |
+
class ConvNextHead(nn.Module):
|
| 83 |
+
def __init__(self, input_dim=1024):
|
| 84 |
+
super().__init__()
|
| 85 |
+
self.net = nn.Sequential(
|
| 86 |
+
nn.Linear(input_dim, 512),
|
| 87 |
+
nn.ReLU(),
|
| 88 |
+
nn.Dropout(0.3),
|
| 89 |
+
nn.Linear(512, 1)
|
| 90 |
+
)
|
| 91 |
+
def forward(self, x): return self.net(x)
|
| 92 |
+
|
| 93 |
+
cn_head = ConvNextHead(input_dim=1024).to(DEVICE)
|
| 94 |
+
cn_head.load_state_dict(torch.load('models/convnext_forensic_head.pth', map_location=DEVICE))
|
| 95 |
+
cn_head.eval()
|
| 96 |
+
|
| 97 |
+
cn_preprocess = transforms.Compose([
|
| 98 |
+
transforms.Resize(256),
|
| 99 |
+
transforms.CenterCrop(224),
|
| 100 |
+
transforms.ToTensor(),
|
| 101 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
|
| 102 |
+
])
|
| 103 |
+
|
| 104 |
+
print("Models loaded")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def load_image_from_url(url: str) -> Image.Image:
|
| 108 |
+
headers = {
|
| 109 |
+
"User-Agent": "Mozilla/5.0"
|
| 110 |
+
}
|
| 111 |
+
response = requests.get(url, headers=headers, timeout=8)
|
| 112 |
+
response.raise_for_status()
|
| 113 |
+
return Image.open(BytesIO(response.content)).convert("RGB")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def load_image_from_data_url(data_url: str) -> Image.Image:
|
| 117 |
+
if "," not in data_url:
|
| 118 |
+
raise ValueError("Invalid data URL")
|
| 119 |
+
|
| 120 |
+
_, encoded = data_url.split(",", 1)
|
| 121 |
+
raw = base64.b64decode(encoded)
|
| 122 |
+
return Image.open(BytesIO(raw)).convert("RGB")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def load_any_image(payload: dict) -> Image.Image:
|
| 126 |
+
if "image_url" in payload and payload["image_url"]:
|
| 127 |
+
src = payload["image_url"]
|
| 128 |
+
if src.startswith("data:"):
|
| 129 |
+
return load_image_from_data_url(src)
|
| 130 |
+
return load_image_from_url(src)
|
| 131 |
+
|
| 132 |
+
if "image" in payload and payload["image"]:
|
| 133 |
+
return load_image_from_data_url(payload["image"])
|
| 134 |
+
|
| 135 |
+
raise ValueError("No image data provided")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def get_explanation(label: str, img_feat_tensor: torch.Tensor, heuristics: dict, confidence: float) -> str:
|
| 139 |
+
"""Combines Zero-Shot CLIP semantic extraction with raw OpenCV Image Processing."""
|
| 140 |
+
noise = heuristics.get('noise_level', 0)
|
| 141 |
+
edges = heuristics.get('edge_density', 0)
|
| 142 |
+
|
| 143 |
+
if label == "AI":
|
| 144 |
+
# find the closest semantic flaw using dot product similarity
|
| 145 |
+
similarity = (100.0 * img_feat_tensor @ ai_text_features.T).softmax(dim=-1)
|
| 146 |
+
top_idx = similarity.argmax().item()
|
| 147 |
+
semantic_reason = AI_FLAWS[top_idx]
|
| 148 |
+
|
| 149 |
+
technical_reason = []
|
| 150 |
+
if noise < 0.025:
|
| 151 |
+
technical_reason.append(f"an unnatural lack of sensor noise ({noise:.3f})")
|
| 152 |
+
if edges < 0.1:
|
| 153 |
+
technical_reason.append("abnormally soft structural contours")
|
| 154 |
+
|
| 155 |
+
tech_str = (" coupled directly with " + " and ".join(technical_reason)) if technical_reason else ""
|
| 156 |
+
return f"The model detected {semantic_reason}{tech_str}.<br/><br/><strong>Assessed as Synthetic ({confidence*100:.1f}% confidence)</strong>"
|
| 157 |
+
else:
|
| 158 |
+
# Feature Extraction: Find closest authentic trait
|
| 159 |
+
similarity = (100.0 * img_feat_tensor @ real_text_features.T).softmax(dim=-1)
|
| 160 |
+
top_idx = similarity.argmax().item()
|
| 161 |
+
semantic_reason = REAL_TRAITS[top_idx]
|
| 162 |
+
|
| 163 |
+
technical_reason = []
|
| 164 |
+
if noise > 0.04:
|
| 165 |
+
technical_reason.append(f"expected natural grain matrix ({noise:.3f})")
|
| 166 |
+
if edges >= 0.1:
|
| 167 |
+
technical_reason.append("well-defined structural boundaries")
|
| 168 |
+
|
| 169 |
+
tech_str = (" supported by " + " and ".join(technical_reason)) if technical_reason else ""
|
| 170 |
+
return f"The model identified {semantic_reason}{tech_str}.<br/><br/><strong>Assessed as Authentic</strong>"
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def extract_simple_features(img: Image.Image):
|
| 174 |
+
"""Extract image characteristics WITHOUT ML"""
|
| 175 |
+
img_rgb = img.convert('RGB')
|
| 176 |
+
img_array = np.array(img_rgb) / 255.0
|
| 177 |
+
|
| 178 |
+
# Edge density (real photos have more natural edges, less uniform)
|
| 179 |
+
edges = np.abs(np.diff(np.mean(img_array, axis=2), axis=0)).mean() + \
|
| 180 |
+
np.abs(np.diff(np.mean(img_array, axis=2), axis=1)).mean()
|
| 181 |
+
|
| 182 |
+
# Noise level (real photos have noise, AI images are smoother)
|
| 183 |
+
img_smooth = np.array(img_rgb.filter(ImageFilter.GaussianBlur(2))) / 255.0
|
| 184 |
+
noise = np.mean((img_array - img_smooth) ** 2) * 1000 # scale for visibility
|
| 185 |
+
|
| 186 |
+
# Color balance (product photos often have strong color gradients)
|
| 187 |
+
hsv = img.convert('HSV')
|
| 188 |
+
hsv_array = np.array(hsv) / 255.0
|
| 189 |
+
color_variance = np.var(hsv_array[:, :, 0]) # hue variance
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
'edge_density': edges,
|
| 193 |
+
'noise_level': noise,
|
| 194 |
+
'color_variance': color_variance,
|
| 195 |
+
'is_too_clean': (noise < 0.02 and edges < 0.1), # product photo signature
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def calibrate_output(img: Image.Image, raw_confidence: float):
|
| 200 |
+
"""Adjust model confidence based on image characteristics"""
|
| 201 |
+
features = extract_simple_features(img)
|
| 202 |
+
|
| 203 |
+
if features['is_too_clean']:
|
| 204 |
+
adjusted_confidence = raw_confidence * 0.67
|
| 205 |
+
else:
|
| 206 |
+
adjusted_confidence = raw_confidence
|
| 207 |
+
adjusted_confidence = min(adjusted_confidence, 0.90)
|
| 208 |
+
|
| 209 |
+
return adjusted_confidence, features
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@app.route("/predict", methods=["POST"])
|
| 213 |
+
def predict():
|
| 214 |
+
try:
|
| 215 |
+
data = request.get_json(force=True, silent=False)
|
| 216 |
+
image = load_any_image(data)
|
| 217 |
+
|
| 218 |
+
openclip_weight = 0.95
|
| 219 |
+
convnext_weight = 0.05
|
| 220 |
+
|
| 221 |
+
# openclip inference
|
| 222 |
+
img_openclip = preprocess(image).unsqueeze(0).to(DEVICE)
|
| 223 |
+
with torch.no_grad():
|
| 224 |
+
features = model.encode_image(img_openclip)
|
| 225 |
+
features = features / features.norm(dim=-1, keepdim=True)
|
| 226 |
+
prob_openclip = float(head(features).item())
|
| 227 |
+
|
| 228 |
+
global_image_features = features.squeeze(0).clone()
|
| 229 |
+
|
| 230 |
+
# convnext inference
|
| 231 |
+
img_cn = cn_preprocess(image).unsqueeze(0).to(DEVICE)
|
| 232 |
+
with torch.no_grad():
|
| 233 |
+
cn_feat = cn_backbone.features(img_cn)
|
| 234 |
+
cn_feat = cn_backbone.avgpool(cn_feat)
|
| 235 |
+
cn_feat = torch.flatten(cn_feat, 1)
|
| 236 |
+
cn_logit = cn_head(cn_feat)
|
| 237 |
+
prob_cn = torch.sigmoid(cn_logit).item()
|
| 238 |
+
|
| 239 |
+
total_ml_weight = openclip_weight + convnext_weight
|
| 240 |
+
if total_ml_weight > 0:
|
| 241 |
+
raw_ensemble_score = (prob_openclip * openclip_weight + prob_cn * convnext_weight) / total_ml_weight
|
| 242 |
+
else:
|
| 243 |
+
raw_ensemble_score = (prob_openclip + prob_cn) / 2.0
|
| 244 |
+
|
| 245 |
+
prob, img_features = calibrate_output(image, raw_ensemble_score)
|
| 246 |
+
|
| 247 |
+
label = "AI" if prob >= 0.75 else "Real"
|
| 248 |
+
confidence = prob if label == "AI" else 1 - prob
|
| 249 |
+
|
| 250 |
+
return jsonify({
|
| 251 |
+
"label": label,
|
| 252 |
+
"confidence": confidence,
|
| 253 |
+
"explanation": get_explanation(label, global_image_features, img_features, confidence),
|
| 254 |
+
"scores": {
|
| 255 |
+
"openclip": prob_openclip,
|
| 256 |
+
"convnext": prob_cn,
|
| 257 |
+
"ensemble_raw": raw_ensemble_score,
|
| 258 |
+
"calibrated_final": prob
|
| 259 |
+
}
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
return jsonify({
|
| 264 |
+
"error": str(e)
|
| 265 |
+
}), 500
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
if __name__ == "__main__":
|
| 269 |
+
app.run(debug=True)
|
datasets/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
extension/background.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ── AI Detector · background.js (service worker) ─────────────────────────
|
| 2 |
+
//
|
| 3 |
+
// Responsibilities:
|
| 4 |
+
// 1. Keep a canonical scan-result cache (in-memory + storage)
|
| 5 |
+
// 2. Handle OPEN_SIDE_PANEL messages from popup
|
| 6 |
+
// 3. Relay GET_SCAN_RESULT / CLEAR_SCAN_RESULT messages to side panel
|
| 7 |
+
// ── Init side panel on action click ───────────────────────────────────────
|
| 8 |
+
chrome.runtime.onInstalled.addListener(() => {
|
| 9 |
+
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch((error) => console.error(error));
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
// ── In-memory result cache ────────────────────────────────────────────────
|
| 13 |
+
// Survives for the lifetime of the service worker (typically a few minutes
|
| 14 |
+
// of inactivity). chrome.storage.local is the durable fallback.
|
| 15 |
+
let cachedResult = null;
|
| 16 |
+
|
| 17 |
+
const activeScans = new Set();
|
| 18 |
+
|
| 19 |
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
| 20 |
+
|
| 21 |
+
// Orchestrate scan logic globally to avoid duplication and sync state
|
| 22 |
+
if (msg.type === "START_SCAN") {
|
| 23 |
+
const tabId = msg.tabId;
|
| 24 |
+
if (activeScans.has(tabId)) {
|
| 25 |
+
sendResponse({ ok: false });
|
| 26 |
+
return false;
|
| 27 |
+
}
|
| 28 |
+
activeScans.add(tabId);
|
| 29 |
+
console.log("SCAN_STARTED");
|
| 30 |
+
|
| 31 |
+
cachedResult = { status: "scanning", tabId, timestamp: Date.now() };
|
| 32 |
+
chrome.storage.local.set({ scanResult: cachedResult, lastScreenshot: msg.screenshot }).catch(()=>{});
|
| 33 |
+
|
| 34 |
+
chrome.scripting.executeScript({
|
| 35 |
+
target: { tabId },
|
| 36 |
+
files: ["content.js"]
|
| 37 |
+
}).then(() => {
|
| 38 |
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Scan timed out after 90 seconds")), 90000));
|
| 39 |
+
const scanPromise = new Promise((resolve, reject) => {
|
| 40 |
+
chrome.tabs.sendMessage(tabId, { type: "SCAN_IMAGES", screenshot: msg.screenshot }, (resp) => {
|
| 41 |
+
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
|
| 42 |
+
else resolve(resp);
|
| 43 |
+
});
|
| 44 |
+
});
|
| 45 |
+
return Promise.race([scanPromise, timeoutPromise]);
|
| 46 |
+
}).then((response) => {
|
| 47 |
+
if (!response) throw new Error("No response from content script");
|
| 48 |
+
|
| 49 |
+
let finalResult;
|
| 50 |
+
if (response.stopped) {
|
| 51 |
+
finalResult = { status: "stopped", risk: 0, count: 0, explanation: null, tabId, timestamp: Date.now() };
|
| 52 |
+
} else if (response.done) {
|
| 53 |
+
const risk = typeof response.risk === "number" ? response.risk : 0;
|
| 54 |
+
const count = typeof response.count === "number" ? response.count : 0;
|
| 55 |
+
const explanation = response.explanation || null;
|
| 56 |
+
const rawConfidence = typeof response.rawConfidence === "number" ? response.rawConfidence : risk / 100;
|
| 57 |
+
finalResult = { status: "done", risk, count, explanation, rawConfidence, tabId, timestamp: Date.now() };
|
| 58 |
+
} else {
|
| 59 |
+
throw new Error(response.error || "Unexpected response");
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
console.log("SCAN_COMPLETED");
|
| 63 |
+
cachedResult = finalResult;
|
| 64 |
+
chrome.storage.local.set({ scanResult: finalResult }).catch(()=>{});
|
| 65 |
+
}).catch(err => {
|
| 66 |
+
console.warn("SCAN_ERROR:", err.message);
|
| 67 |
+
const errResult = { status: "error", error: err.message, tabId, timestamp: Date.now() };
|
| 68 |
+
cachedResult = errResult;
|
| 69 |
+
chrome.storage.local.set({ scanResult: errResult }).catch(()=>{});
|
| 70 |
+
}).finally(() => {
|
| 71 |
+
activeScans.delete(tabId);
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
sendResponse({ ok: true });
|
| 75 |
+
return false;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if (msg.type === "STOP_SCAN") {
|
| 79 |
+
console.log("SCAN_STOPPED");
|
| 80 |
+
chrome.tabs.sendMessage(msg.tabId, { type: "STOP_SCAN" }, () => {
|
| 81 |
+
void chrome.runtime.lastError;
|
| 82 |
+
});
|
| 83 |
+
sendResponse({ ok: true });
|
| 84 |
+
return false;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Side panel → background: retrieve latest scan result
|
| 88 |
+
if (msg.type === "GET_SCAN_RESULT") {
|
| 89 |
+
if (cachedResult) {
|
| 90 |
+
sendResponse({ result: cachedResult });
|
| 91 |
+
return false;
|
| 92 |
+
}
|
| 93 |
+
// Fall back to storage (service worker may have been restarted)
|
| 94 |
+
chrome.storage.local.get("scanResult", (data) => {
|
| 95 |
+
if (chrome.runtime.lastError) {
|
| 96 |
+
console.warn("[BG] storage read error:", chrome.runtime.lastError.message);
|
| 97 |
+
sendResponse({ result: null });
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
cachedResult = data.scanResult || null;
|
| 101 |
+
sendResponse({ result: cachedResult });
|
| 102 |
+
});
|
| 103 |
+
return true; // async response
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
// Popup or side panel: clear stored result
|
| 108 |
+
if (msg.type === "CLEAR_SCAN_RESULT") {
|
| 109 |
+
cachedResult = null;
|
| 110 |
+
chrome.storage.local.remove("scanResult");
|
| 111 |
+
sendResponse({ ok: true });
|
| 112 |
+
return false;
|
| 113 |
+
}
|
| 114 |
+
});
|
extension/content.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
if (!window.__AI_DETECTOR_LOADED__) {
|
| 2 |
+
window.__AI_DETECTOR_LOADED__ = true;
|
| 3 |
+
|
| 4 |
+
const OVERLAY_ATTR = "data-ai-detector-overlay";
|
| 5 |
+
const TIP_ATTR = "data-ai-detector-tip";
|
| 6 |
+
let activeOverlays = [];
|
| 7 |
+
|
| 8 |
+
// ── Stop-scan flag ──────────────────────────────────────────────────────
|
| 9 |
+
// Reset on every new SCAN_IMAGES request; set to true by STOP_SCAN.
|
| 10 |
+
let stopRequested = false;
|
| 11 |
+
|
| 12 |
+
/* ── Inject shared tooltip styles once ── */
|
| 13 |
+
if (!document.getElementById("__ai_detector_styles__")) {
|
| 14 |
+
const link = document.createElement("link");
|
| 15 |
+
link.rel = "stylesheet";
|
| 16 |
+
link.href = "https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap";
|
| 17 |
+
document.head.appendChild(link);
|
| 18 |
+
|
| 19 |
+
const style = document.createElement("style");
|
| 20 |
+
style.id = "__ai_detector_styles__";
|
| 21 |
+
style.textContent = `
|
| 22 |
+
[${TIP_ATTR}="1"] {
|
| 23 |
+
--radai-mono: "IBM Plex Mono", monospace, sans-serif;
|
| 24 |
+
--radai-sans: "Inter", -apple-system, system-ui, sans-serif;
|
| 25 |
+
position: fixed;
|
| 26 |
+
pointer-events: none;
|
| 27 |
+
z-index: 2147483647;
|
| 28 |
+
background: #ffffff;
|
| 29 |
+
border: 1px solid #d4d4d4;
|
| 30 |
+
border-radius: 4px;
|
| 31 |
+
padding: 14px;
|
| 32 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 33 |
+
font-family: var(--radai-sans);
|
| 34 |
+
color: #111111;
|
| 35 |
+
min-width: 200px;
|
| 36 |
+
max-width: 320px;
|
| 37 |
+
opacity: 0;
|
| 38 |
+
transform: translateY(4px);
|
| 39 |
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
| 40 |
+
line-height: 1.5;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
[${TIP_ATTR}="1"].visible {
|
| 44 |
+
opacity: 1;
|
| 45 |
+
transform: translateY(0);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
[${TIP_ATTR}="1"] .tip-header {
|
| 49 |
+
display: flex;
|
| 50 |
+
align-items: flex-start;
|
| 51 |
+
justify-content: space-between;
|
| 52 |
+
margin-bottom: 10px;
|
| 53 |
+
gap: 12px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
[${TIP_ATTR}="1"] .tip-label {
|
| 57 |
+
font-size: 18px;
|
| 58 |
+
font-weight: 700;
|
| 59 |
+
letter-spacing: -0.5px;
|
| 60 |
+
line-height: 1;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
[${TIP_ATTR}="1"] .tip-badge {
|
| 64 |
+
font-size: 18px;
|
| 65 |
+
font-weight: 700;
|
| 66 |
+
font-family: var(--radai-mono);
|
| 67 |
+
letter-spacing: -0.5px;
|
| 68 |
+
line-height: 1;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
[${TIP_ATTR}="1"] .tip-divider {
|
| 72 |
+
height: 1px;
|
| 73 |
+
background: #e5e5e5;
|
| 74 |
+
margin-bottom: 10px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
[${TIP_ATTR}="1"] .tip-text {
|
| 78 |
+
font-size: 12px;
|
| 79 |
+
color: #525252;
|
| 80 |
+
font-weight: 400;
|
| 81 |
+
line-height: 1.5;
|
| 82 |
+
}
|
| 83 |
+
`;
|
| 84 |
+
document.head.appendChild(style);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* ── Clear all overlays ── */
|
| 88 |
+
function clearBoxes() {
|
| 89 |
+
activeOverlays.forEach(o => o.box.remove());
|
| 90 |
+
activeOverlays = [];
|
| 91 |
+
document.querySelectorAll(`[${TIP_ATTR}="1"]`).forEach(el => el.remove());
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
/* ── Get a muted color per label ── */
|
| 95 |
+
function labelColor(label) {
|
| 96 |
+
return label === "AI" ? "#e53e3e" : "#38a169";
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/* ── Strip HTML ── */
|
| 100 |
+
function stripHtml(htmlStr) {
|
| 101 |
+
if (!htmlStr) return "Analysis complete.";
|
| 102 |
+
const tmp = document.createElement("div");
|
| 103 |
+
tmp.innerHTML = htmlStr;
|
| 104 |
+
return tmp.textContent || tmp.innerText || "";
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* ── Build the tooltip element ── */
|
| 108 |
+
function buildTooltip(data) {
|
| 109 |
+
const rawExp = data.explanation || "";
|
| 110 |
+
const fullExp = stripHtml(rawExp);
|
| 111 |
+
const color = labelColor(data.label);
|
| 112 |
+
const pct = Math.round(data.confidence * 100);
|
| 113 |
+
|
| 114 |
+
const tip = document.createElement("div");
|
| 115 |
+
tip.setAttribute(TIP_ATTR, "1");
|
| 116 |
+
|
| 117 |
+
tip.innerHTML = `
|
| 118 |
+
<div class="tip-header">
|
| 119 |
+
<span class="tip-label" style="color:${color}">${data.label === "AI" ? "AI Generated" : "Likely Real"}</span>
|
| 120 |
+
<span class="tip-badge" style="color:${color};">${pct}%</span>
|
| 121 |
+
</div>
|
| 122 |
+
<div class="tip-divider"></div>
|
| 123 |
+
<div class="tip-text">${fullExp}</div>
|
| 124 |
+
`;
|
| 125 |
+
|
| 126 |
+
return tip;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* ── Position tooltip near image, keeping it in viewport ── */
|
| 130 |
+
function positionTooltip(tip, rect) {
|
| 131 |
+
const TIP_W = 172;
|
| 132 |
+
const TIP_H = 118;
|
| 133 |
+
const PAD = 10;
|
| 134 |
+
|
| 135 |
+
let left = rect.left;
|
| 136 |
+
let top = rect.top - TIP_H - 10;
|
| 137 |
+
|
| 138 |
+
// Flip below if not enough room above
|
| 139 |
+
if (top < PAD) top = rect.bottom + 10;
|
| 140 |
+
|
| 141 |
+
// Clamp horizontally
|
| 142 |
+
if (left + TIP_W > window.innerWidth - PAD) {
|
| 143 |
+
left = window.innerWidth - TIP_W - PAD;
|
| 144 |
+
}
|
| 145 |
+
if (left < PAD) left = PAD;
|
| 146 |
+
|
| 147 |
+
tip.style.left = `${left}px`;
|
| 148 |
+
tip.style.top = `${top}px`;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/* ── Draw border box + attach hover tooltip ── */
|
| 152 |
+
function drawBox(img, data) {
|
| 153 |
+
const rect = img.getBoundingClientRect();
|
| 154 |
+
const color = labelColor(data.label);
|
| 155 |
+
|
| 156 |
+
// Border overlay box
|
| 157 |
+
const box = document.createElement("div");
|
| 158 |
+
box.setAttribute(OVERLAY_ATTR, "1");
|
| 159 |
+
box.style.cssText = `
|
| 160 |
+
position: absolute;
|
| 161 |
+
left: ${rect.left + window.scrollX}px;
|
| 162 |
+
top: ${rect.top + window.scrollY}px;
|
| 163 |
+
width: ${rect.width}px;
|
| 164 |
+
height: ${rect.height}px;
|
| 165 |
+
border: 3px solid ${color};
|
| 166 |
+
border-radius: 10px;
|
| 167 |
+
pointer-events: none;
|
| 168 |
+
z-index: 999998;
|
| 169 |
+
opacity: 0;
|
| 170 |
+
transition: opacity 0.25s ease;
|
| 171 |
+
box-shadow: 0 0 0 1px rgba(255,255,255,0.15), 0 4px 20px ${color}33;
|
| 172 |
+
`;
|
| 173 |
+
|
| 174 |
+
// Small inline label tag
|
| 175 |
+
const tag = document.createElement("div");
|
| 176 |
+
tag.style.cssText = `
|
| 177 |
+
position: absolute;
|
| 178 |
+
top: -1px;
|
| 179 |
+
left: -1px;
|
| 180 |
+
padding: 3px 8px;
|
| 181 |
+
font-size: 11px;
|
| 182 |
+
font-weight: 600;
|
| 183 |
+
font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
| 184 |
+
color: #ffffff;
|
| 185 |
+
background: ${color};
|
| 186 |
+
border-radius: 9px 0 9px 0;
|
| 187 |
+
letter-spacing: 0.2px;
|
| 188 |
+
line-height: 1.6;
|
| 189 |
+
`;
|
| 190 |
+
tag.textContent = `${data.label} · ${Math.round(data.confidence * 100)}%`;
|
| 191 |
+
|
| 192 |
+
box.appendChild(tag);
|
| 193 |
+
document.body.appendChild(box);
|
| 194 |
+
activeOverlays.push({ img, box });
|
| 195 |
+
|
| 196 |
+
// Fade in
|
| 197 |
+
requestAnimationFrame(() => { box.style.opacity = "1"; });
|
| 198 |
+
|
| 199 |
+
// Hover tooltip (attached to the img element)
|
| 200 |
+
if (!img.dataset.aiDetectorBound) {
|
| 201 |
+
img.dataset.aiDetectorBound = "1";
|
| 202 |
+
|
| 203 |
+
let activeTip = null;
|
| 204 |
+
|
| 205 |
+
img.addEventListener("mouseenter", () => {
|
| 206 |
+
// Remove any existing tip
|
| 207 |
+
document.querySelectorAll(`[${TIP_ATTR}="1"]`).forEach(el => el.remove());
|
| 208 |
+
|
| 209 |
+
const r = img.getBoundingClientRect();
|
| 210 |
+
const tip = buildTooltip(data);
|
| 211 |
+
document.body.appendChild(tip);
|
| 212 |
+
|
| 213 |
+
positionTooltip(tip, r);
|
| 214 |
+
activeTip = tip;
|
| 215 |
+
|
| 216 |
+
// Trigger fade-in on next frame
|
| 217 |
+
requestAnimationFrame(() => { tip.classList.add("visible"); });
|
| 218 |
+
});
|
| 219 |
+
|
| 220 |
+
img.addEventListener("mouseleave", () => {
|
| 221 |
+
if (activeTip) {
|
| 222 |
+
activeTip.classList.remove("visible");
|
| 223 |
+
const dying = activeTip;
|
| 224 |
+
activeTip = null;
|
| 225 |
+
setTimeout(() => dying.remove(), 220);
|
| 226 |
+
}
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// Re-position if user scrolls while hovering
|
| 230 |
+
window.addEventListener("scroll", () => {
|
| 231 |
+
if (activeTip) {
|
| 232 |
+
const r = img.getBoundingClientRect();
|
| 233 |
+
positionTooltip(activeTip, r);
|
| 234 |
+
}
|
| 235 |
+
}, { passive: true });
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
/* ── Recalculate Box Positions on Resize ── */
|
| 240 |
+
let resizeTimeout;
|
| 241 |
+
window.addEventListener("resize", () => {
|
| 242 |
+
clearTimeout(resizeTimeout);
|
| 243 |
+
resizeTimeout = setTimeout(() => {
|
| 244 |
+
activeOverlays.forEach(({ img, box }) => {
|
| 245 |
+
if (!img.isConnected) {
|
| 246 |
+
box.style.display = "none";
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const rect = img.getBoundingClientRect();
|
| 251 |
+
|
| 252 |
+
// Hide box if image becomes invisible or too small
|
| 253 |
+
if (rect.width === 0 || rect.height === 0) {
|
| 254 |
+
box.style.display = "none";
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
box.style.display = "block";
|
| 259 |
+
box.style.left = `${rect.left + window.scrollX}px`;
|
| 260 |
+
box.style.top = `${rect.top + window.scrollY}px`;
|
| 261 |
+
box.style.width = `${rect.width}px`;
|
| 262 |
+
box.style.height = `${rect.height}px`;
|
| 263 |
+
});
|
| 264 |
+
}, 150); // slight debounce ensures sidebar finishes dragging/animating
|
| 265 |
+
}, { passive: true });
|
| 266 |
+
|
| 267 |
+
/* ── Helpers ── */
|
| 268 |
+
function loadImage(src) {
|
| 269 |
+
return new Promise((resolve, reject) => {
|
| 270 |
+
const image = new Image();
|
| 271 |
+
image.onload = () => resolve(image);
|
| 272 |
+
image.onerror = reject;
|
| 273 |
+
image.src = src;
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
async function cropVisibleImageFromScreenshot(screenshotDataUrl, rect) {
|
| 278 |
+
const shot = await loadImage(screenshotDataUrl);
|
| 279 |
+
|
| 280 |
+
const scaleX = shot.width / window.innerWidth;
|
| 281 |
+
const scaleY = shot.height / window.innerHeight;
|
| 282 |
+
|
| 283 |
+
const sx = Math.max(0, rect.left * scaleX);
|
| 284 |
+
const sy = Math.max(0, rect.top * scaleY);
|
| 285 |
+
const sw = Math.max(1, rect.width * scaleX);
|
| 286 |
+
const sh = Math.max(1, rect.height * scaleY);
|
| 287 |
+
|
| 288 |
+
const canvas = document.createElement("canvas");
|
| 289 |
+
canvas.width = Math.max(1, Math.round(sw));
|
| 290 |
+
canvas.height = Math.max(1, Math.round(sh));
|
| 291 |
+
|
| 292 |
+
const ctx = canvas.getContext("2d");
|
| 293 |
+
ctx.drawImage(shot, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
| 294 |
+
|
| 295 |
+
return canvas.toDataURL("image/png");
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
async function predictWithUrlOrFallback(img, screenshotDataUrl) {
|
| 299 |
+
const url = img.currentSrc || img.src;
|
| 300 |
+
|
| 301 |
+
if (url && !url.startsWith("blob:")) {
|
| 302 |
+
try {
|
| 303 |
+
const res = await fetch("http://127.0.0.1:5000/predict", {
|
| 304 |
+
method: "POST",
|
| 305 |
+
headers: { "Content-Type": "application/json" },
|
| 306 |
+
body: JSON.stringify({ image_url: url })
|
| 307 |
+
});
|
| 308 |
+
if (res.ok) return await res.json();
|
| 309 |
+
} catch (err) {
|
| 310 |
+
console.log("URL path failed, falling back to screenshot crop:", err);
|
| 311 |
+
}
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
const rect = img.getBoundingClientRect();
|
| 315 |
+
const cropDataUrl = await cropVisibleImageFromScreenshot(screenshotDataUrl, rect);
|
| 316 |
+
|
| 317 |
+
const res = await fetch("http://127.0.0.1:5000/predict", {
|
| 318 |
+
method: "POST",
|
| 319 |
+
headers: { "Content-Type": "application/json" },
|
| 320 |
+
body: JSON.stringify({ image: cropDataUrl })
|
| 321 |
+
});
|
| 322 |
+
|
| 323 |
+
if (!res.ok) throw new Error(`Prediction failed with status ${res.status}`);
|
| 324 |
+
return await res.json();
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
async function scanImages(screenshotDataUrl) {
|
| 328 |
+
clearBoxes();
|
| 329 |
+
stopRequested = false;
|
| 330 |
+
|
| 331 |
+
const images = Array.from(document.querySelectorAll("img")).filter(img => {
|
| 332 |
+
const rect = img.getBoundingClientRect();
|
| 333 |
+
return (
|
| 334 |
+
img.complete &&
|
| 335 |
+
img.naturalWidth > 0 &&
|
| 336 |
+
rect.width >= 80 &&
|
| 337 |
+
rect.height >= 80
|
| 338 |
+
);
|
| 339 |
+
});
|
| 340 |
+
|
| 341 |
+
let count = 0;
|
| 342 |
+
let aiCount = 0;
|
| 343 |
+
let lastExplanation = null;
|
| 344 |
+
|
| 345 |
+
for (const img of images) {
|
| 346 |
+
// Check stop flag before processing each image
|
| 347 |
+
if (stopRequested) {
|
| 348 |
+
return { count, aiCount, risk: count > 0 ? Math.round((aiCount / count) * 100) : 0, explanation: lastExplanation, stopped: true, total: images.length };
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
try {
|
| 352 |
+
const data = await predictWithUrlOrFallback(img, screenshotDataUrl);
|
| 353 |
+
|
| 354 |
+
if (stopRequested) {
|
| 355 |
+
return { count, aiCount, risk: count > 0 ? Math.round((aiCount / count) * 100) : 0, explanation: lastExplanation, stopped: true, total: images.length };
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
if (data.label === "AI") {
|
| 359 |
+
aiCount++;
|
| 360 |
+
}
|
| 361 |
+
// Save the last explanation regardless of label to provide some feedback
|
| 362 |
+
if (data.explanation) lastExplanation = data.explanation;
|
| 363 |
+
|
| 364 |
+
drawBox(img, data);
|
| 365 |
+
count++;
|
| 366 |
+
} catch (err) {
|
| 367 |
+
console.log("Skipping image:", err);
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
const risk = count > 0 ? Math.round((aiCount / count) * 100) : 0;
|
| 372 |
+
return { count, risk, explanation: lastExplanation, stopped: false };
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
|
| 376 |
+
if (req.action === "SCAN_IMAGES") {
|
| 377 |
+
scanImages(req.screenshot)
|
| 378 |
+
.then(result => {
|
| 379 |
+
if (result.stopped) {
|
| 380 |
+
sendResponse({ done: false, stopped: true, count: result.count, aiCount: result.aiCount, total: result.total, risk: result.risk, explanation: result.explanation });
|
| 381 |
+
} else {
|
| 382 |
+
sendResponse({
|
| 383 |
+
done: true,
|
| 384 |
+
count: result.count,
|
| 385 |
+
risk: result.risk,
|
| 386 |
+
explanation: result.explanation
|
| 387 |
+
});
|
| 388 |
+
}
|
| 389 |
+
})
|
| 390 |
+
.catch(err => {
|
| 391 |
+
console.error(err);
|
| 392 |
+
sendResponse({ done: false, error: String(err) });
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
return true; // keep message channel open
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
if (req.action === "STOP_SCAN") {
|
| 399 |
+
stopRequested = true;
|
| 400 |
+
// No sendResponse needed; popup will receive the stopped flag via SCAN_IMAGES response
|
| 401 |
+
return false;
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
}
|
extension/manifest.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"manifest_version": 3,
|
| 3 |
+
"name": "RADAI",
|
| 4 |
+
"version": "2.0",
|
| 5 |
+
"description": "Detect AI-generated images on webpages.",
|
| 6 |
+
"permissions": [
|
| 7 |
+
"activeTab",
|
| 8 |
+
"tabs",
|
| 9 |
+
"scripting",
|
| 10 |
+
"storage",
|
| 11 |
+
"sidePanel"
|
| 12 |
+
],
|
| 13 |
+
"host_permissions": [
|
| 14 |
+
"http://127.0.0.1:5000/*"
|
| 15 |
+
],
|
| 16 |
+
"action": {},
|
| 17 |
+
"side_panel": {
|
| 18 |
+
"default_path": "sidepanel.html"
|
| 19 |
+
},
|
| 20 |
+
"background": {
|
| 21 |
+
"service_worker": "background.js"
|
| 22 |
+
},
|
| 23 |
+
"content_scripts": [
|
| 24 |
+
{
|
| 25 |
+
"matches": ["<all_urls>"],
|
| 26 |
+
"js": ["content.js"]
|
| 27 |
+
}
|
| 28 |
+
]
|
| 29 |
+
}
|
extension/sidepanel.html
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
|
| 4 |
+
<head>
|
| 5 |
+
<meta charset="UTF-8" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>RADAI / Analysis</title>
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 9 |
+
<link
|
| 10 |
+
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Inter:wght@400;500;600;700&display=swap"
|
| 11 |
+
rel="stylesheet" />
|
| 12 |
+
<style>
|
| 13 |
+
/* ─────────────────────────────────────────────────────────────
|
| 14 |
+
DESIGN SYSTEM — forensic tool aesthetic
|
| 15 |
+
Palette: near-white bg, near-black text, grey structure,
|
| 16 |
+
muted red (#b91c1c) for danger, amber for warnings.
|
| 17 |
+
No gradients. No box-shadows beyond hairlines. No rounding > 6px.
|
| 18 |
+
───────────────────────────────────────────────────────────── */
|
| 19 |
+
:root {
|
| 20 |
+
--bg: #f4f4f4;
|
| 21 |
+
--surface: #ffffff;
|
| 22 |
+
--border: #d4d4d4;
|
| 23 |
+
--border-lg: #e5e5e5;
|
| 24 |
+
--text: #111111;
|
| 25 |
+
--text2: #525252;
|
| 26 |
+
--text3: #737373;
|
| 27 |
+
--danger: #b91c1c;
|
| 28 |
+
--danger-dim: rgba(185, 28, 28, 0.07);
|
| 29 |
+
--warn: #92400e;
|
| 30 |
+
--warn-dim: rgba(146, 64, 14, 0.07);
|
| 31 |
+
--ok: #14532d;
|
| 32 |
+
--ok-dim: rgba(20, 83, 45, 0.07);
|
| 33 |
+
--mono: 'IBM Plex Mono', monospace;
|
| 34 |
+
--sans: 'Inter', system-ui, sans-serif;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
[data-theme="dark"] {
|
| 38 |
+
--bg: #0f0f0f;
|
| 39 |
+
--surface: #1a1a1a;
|
| 40 |
+
--border: #303030;
|
| 41 |
+
--border-lg: #242424;
|
| 42 |
+
--text: #e5e5e5;
|
| 43 |
+
--text2: #a3a3a3;
|
| 44 |
+
--text3: #737373;
|
| 45 |
+
--danger: #ef4444;
|
| 46 |
+
--danger-dim: rgba(239, 68, 68, 0.08);
|
| 47 |
+
--warn: #f59e0b;
|
| 48 |
+
--warn-dim: rgba(245, 158, 11, 0.08);
|
| 49 |
+
--ok: #22c55e;
|
| 50 |
+
--ok-dim: rgba(34, 197, 94, 0.08);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
*,
|
| 54 |
+
*::before,
|
| 55 |
+
*::after {
|
| 56 |
+
box-sizing: border-box;
|
| 57 |
+
margin: 0;
|
| 58 |
+
padding: 0;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
html,
|
| 62 |
+
body {
|
| 63 |
+
height: 100%;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
body {
|
| 67 |
+
font-family: var(--sans);
|
| 68 |
+
background: var(--bg);
|
| 69 |
+
color: var(--text);
|
| 70 |
+
font-size: 13px;
|
| 71 |
+
line-height: 1.5;
|
| 72 |
+
-webkit-font-smoothing: antialiased;
|
| 73 |
+
overflow: hidden;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/* ── Panel ── */
|
| 77 |
+
.panel {
|
| 78 |
+
height: 100vh;
|
| 79 |
+
display: flex;
|
| 80 |
+
flex-direction: column;
|
| 81 |
+
overflow-y: auto;
|
| 82 |
+
overflow-x: hidden;
|
| 83 |
+
scrollbar-width: thin;
|
| 84 |
+
scrollbar-color: var(--border) transparent;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.panel::-webkit-scrollbar {
|
| 88 |
+
width: 4px;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.panel::-webkit-scrollbar-thumb {
|
| 92 |
+
background: var(--border);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* ── Top bar ── */
|
| 96 |
+
.topbar {
|
| 97 |
+
display: flex;
|
| 98 |
+
align-items: center;
|
| 99 |
+
justify-content: space-between;
|
| 100 |
+
padding: 10px 14px;
|
| 101 |
+
background: var(--surface);
|
| 102 |
+
border-bottom: 1px solid var(--border);
|
| 103 |
+
flex-shrink: 0;
|
| 104 |
+
position: sticky;
|
| 105 |
+
top: 0;
|
| 106 |
+
z-index: 100;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.brand {
|
| 110 |
+
display: flex;
|
| 111 |
+
align-items: center;
|
| 112 |
+
gap: 8px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.brand-mark {
|
| 116 |
+
width: 20px;
|
| 117 |
+
height: 20px;
|
| 118 |
+
display: grid;
|
| 119 |
+
place-items: center;
|
| 120 |
+
color: var(--text);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.brand-mark svg {
|
| 124 |
+
width: 18px;
|
| 125 |
+
height: 18px;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
.brand-name {
|
| 129 |
+
font-size: 12px;
|
| 130 |
+
font-weight: 700;
|
| 131 |
+
letter-spacing: 0.5px;
|
| 132 |
+
text-transform: uppercase;
|
| 133 |
+
color: var(--text);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.brand-sep {
|
| 137 |
+
width: 1px;
|
| 138 |
+
height: 12px;
|
| 139 |
+
background: var(--border);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.brand-sub {
|
| 143 |
+
font-size: 10px;
|
| 144 |
+
color: var(--text3);
|
| 145 |
+
font-family: var(--mono);
|
| 146 |
+
letter-spacing: 0.5px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.topbar-right {
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: center;
|
| 152 |
+
gap: 8px;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.topbar-chip {
|
| 156 |
+
font-size: 10px;
|
| 157 |
+
font-family: var(--mono);
|
| 158 |
+
font-weight: 500;
|
| 159 |
+
color: var(--text3);
|
| 160 |
+
border: 1px solid var(--border);
|
| 161 |
+
padding: 2px 8px;
|
| 162 |
+
border-radius: 2px;
|
| 163 |
+
white-space: nowrap;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.theme-toggle {
|
| 167 |
+
font-size: 10px;
|
| 168 |
+
font-family: var(--mono);
|
| 169 |
+
font-weight: 500;
|
| 170 |
+
color: var(--text2);
|
| 171 |
+
background: none;
|
| 172 |
+
border: 1px solid var(--border);
|
| 173 |
+
padding: 2px 8px;
|
| 174 |
+
border-radius: 2px;
|
| 175 |
+
cursor: pointer;
|
| 176 |
+
transition: color 0.1s, border-color 0.1s;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.theme-toggle:hover {
|
| 180 |
+
color: var(--text);
|
| 181 |
+
border-color: var(--text2);
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
/* ── Content area ── */
|
| 185 |
+
.content {
|
| 186 |
+
flex: 1;
|
| 187 |
+
padding: 14px;
|
| 188 |
+
display: flex;
|
| 189 |
+
flex-direction: column;
|
| 190 |
+
gap: 12px;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* ── Empty state ── */
|
| 194 |
+
.empty {
|
| 195 |
+
flex: 1;
|
| 196 |
+
display: flex;
|
| 197 |
+
flex-direction: column;
|
| 198 |
+
align-items: center;
|
| 199 |
+
justify-content: center;
|
| 200 |
+
gap: 8px;
|
| 201 |
+
text-align: center;
|
| 202 |
+
padding: 40px 24px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
.empty-icon {
|
| 206 |
+
display: flex;
|
| 207 |
+
align-items: center;
|
| 208 |
+
justify-content: center;
|
| 209 |
+
width: 48px;
|
| 210 |
+
height: 48px;
|
| 211 |
+
border: 1px solid var(--border);
|
| 212 |
+
border-radius: 4px;
|
| 213 |
+
color: var(--text3);
|
| 214 |
+
margin-bottom: 6px;
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.empty-icon svg {
|
| 218 |
+
width: 22px;
|
| 219 |
+
height: 22px;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.empty-title {
|
| 223 |
+
font-size: 14px;
|
| 224 |
+
font-weight: 600;
|
| 225 |
+
color: var(--text);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.empty-desc {
|
| 229 |
+
font-size: 12px;
|
| 230 |
+
color: var(--text3);
|
| 231 |
+
line-height: 1.55;
|
| 232 |
+
max-width: 220px;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* ── Section label ── */
|
| 236 |
+
.section-label {
|
| 237 |
+
font-size: 10px;
|
| 238 |
+
font-family: var(--mono);
|
| 239 |
+
font-weight: 500;
|
| 240 |
+
letter-spacing: 0.8px;
|
| 241 |
+
text-transform: uppercase;
|
| 242 |
+
color: var(--text3);
|
| 243 |
+
margin-bottom: 6px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/* ── Verdict card ── */
|
| 247 |
+
.verdict-card {
|
| 248 |
+
background: var(--surface);
|
| 249 |
+
border: 1px solid var(--border);
|
| 250 |
+
border-radius: 4px;
|
| 251 |
+
padding: 14px;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
.verdict-top {
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: flex-start;
|
| 257 |
+
justify-content: space-between;
|
| 258 |
+
gap: 12px;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.verdict-label {
|
| 262 |
+
font-size: 28px;
|
| 263 |
+
font-weight: 700;
|
| 264 |
+
letter-spacing: -1px;
|
| 265 |
+
line-height: 1;
|
| 266 |
+
color: var(--text);
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.verdict-meta {
|
| 270 |
+
display: flex;
|
| 271 |
+
flex-direction: column;
|
| 272 |
+
align-items: flex-end;
|
| 273 |
+
gap: 4px;
|
| 274 |
+
flex-shrink: 0;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.confidence-value {
|
| 278 |
+
font-size: 28px;
|
| 279 |
+
font-weight: 700;
|
| 280 |
+
letter-spacing: -1px;
|
| 281 |
+
line-height: 1;
|
| 282 |
+
font-family: var(--mono);
|
| 283 |
+
color: var(--text);
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.confidence-label {
|
| 287 |
+
font-size: 10px;
|
| 288 |
+
font-family: var(--mono);
|
| 289 |
+
color: var(--text3);
|
| 290 |
+
letter-spacing: 0.5px;
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
.verdict-desc {
|
| 294 |
+
font-size: 12px;
|
| 295 |
+
color: var(--text2);
|
| 296 |
+
margin-top: 8px;
|
| 297 |
+
line-height: 1.5;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/* ── Status bar ── */
|
| 301 |
+
.status-bar {
|
| 302 |
+
margin-top: 12px;
|
| 303 |
+
padding-top: 10px;
|
| 304 |
+
border-top: 1px solid var(--border-lg);
|
| 305 |
+
display: flex;
|
| 306 |
+
align-items: center;
|
| 307 |
+
gap: 6px;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.status-dot {
|
| 311 |
+
width: 6px;
|
| 312 |
+
height: 6px;
|
| 313 |
+
border-radius: 50%;
|
| 314 |
+
flex-shrink: 0;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
.status-text {
|
| 318 |
+
font-size: 11px;
|
| 319 |
+
font-family: var(--mono);
|
| 320 |
+
color: var(--text2);
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/* ── Risk bar ── */
|
| 324 |
+
.risk-bar-wrap {
|
| 325 |
+
margin-top: 12px;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.risk-bar-header {
|
| 329 |
+
display: flex;
|
| 330 |
+
justify-content: space-between;
|
| 331 |
+
font-size: 10px;
|
| 332 |
+
font-family: var(--mono);
|
| 333 |
+
color: var(--text3);
|
| 334 |
+
margin-bottom: 5px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.risk-bar-track {
|
| 338 |
+
height: 3px;
|
| 339 |
+
background: var(--border);
|
| 340 |
+
border-radius: 2px;
|
| 341 |
+
overflow: hidden;
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
.risk-bar-fill {
|
| 345 |
+
height: 100%;
|
| 346 |
+
width: 0%;
|
| 347 |
+
border-radius: 2px;
|
| 348 |
+
transition: width 0.6s ease;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
/* ── How it works ── */
|
| 352 |
+
.how-it-works-card {
|
| 353 |
+
background: var(--surface);
|
| 354 |
+
border: 1px solid var(--border);
|
| 355 |
+
border-radius: 4px;
|
| 356 |
+
overflow: hidden;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.hiw-header {
|
| 360 |
+
padding: 10px 14px;
|
| 361 |
+
border-bottom: 1px solid var(--border-lg);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.hiw-body {
|
| 365 |
+
padding: 12px 14px;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
.hiw-text {
|
| 369 |
+
font-size: 12px;
|
| 370 |
+
color: var(--text2);
|
| 371 |
+
line-height: 1.6;
|
| 372 |
+
margin-bottom: 8px;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.hiw-list {
|
| 376 |
+
list-style-type: disc;
|
| 377 |
+
padding-left: 20px;
|
| 378 |
+
margin-bottom: 0px;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.hiw-list li {
|
| 382 |
+
font-size: 12px;
|
| 383 |
+
color: var(--text2);
|
| 384 |
+
line-height: 1.6;
|
| 385 |
+
margin-bottom: 4px;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
/* ── Footer ── */
|
| 390 |
+
.panel-footer {
|
| 391 |
+
padding: 8px 14px;
|
| 392 |
+
border-top: 1px solid var(--border);
|
| 393 |
+
display: flex;
|
| 394 |
+
align-items: center;
|
| 395 |
+
justify-content: space-between;
|
| 396 |
+
flex-shrink: 0;
|
| 397 |
+
background: var(--surface);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
.footer-left {
|
| 401 |
+
font-size: 10px;
|
| 402 |
+
font-family: var(--mono);
|
| 403 |
+
color: var(--text3);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
.footer-right {
|
| 407 |
+
font-size: 10px;
|
| 408 |
+
font-family: var(--mono);
|
| 409 |
+
color: var(--text3);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
/* ── Loading state ── */
|
| 413 |
+
.loading-bar {
|
| 414 |
+
height: 2px;
|
| 415 |
+
background: var(--border);
|
| 416 |
+
position: relative;
|
| 417 |
+
overflow: hidden;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.loading-bar::after {
|
| 421 |
+
content: '';
|
| 422 |
+
position: absolute;
|
| 423 |
+
left: -40%;
|
| 424 |
+
top: 0;
|
| 425 |
+
width: 40%;
|
| 426 |
+
height: 100%;
|
| 427 |
+
background: var(--text3);
|
| 428 |
+
animation: loading-slide 1.2s ease-in-out infinite;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
@keyframes loading-slide {
|
| 432 |
+
0% {
|
| 433 |
+
left: -40%;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
100% {
|
| 437 |
+
left: 110%;
|
| 438 |
+
}
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* ── Data container toggle ── */
|
| 442 |
+
#dataContainer {
|
| 443 |
+
display: none;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
#dataContainer.visible {
|
| 447 |
+
display: contents;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
#emptyState {
|
| 451 |
+
display: flex;
|
| 452 |
+
flex-direction: column;
|
| 453 |
+
align-items: center;
|
| 454 |
+
justify-content: center;
|
| 455 |
+
flex: 1;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
#emptyState.hidden {
|
| 459 |
+
display: none;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* ── Stopped state ── */
|
| 463 |
+
.stopped-banner {
|
| 464 |
+
background: var(--warn-dim);
|
| 465 |
+
border: 1px solid currentColor;
|
| 466 |
+
color: var(--warn);
|
| 467 |
+
border-radius: 3px;
|
| 468 |
+
padding: 9px 12px;
|
| 469 |
+
font-size: 11px;
|
| 470 |
+
font-family: var(--mono);
|
| 471 |
+
margin-bottom: 12px;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* ── Action buttons ── */
|
| 475 |
+
.btn-row {
|
| 476 |
+
display: grid;
|
| 477 |
+
grid-template-columns: 1fr auto;
|
| 478 |
+
gap: 6px;
|
| 479 |
+
margin-bottom: 2px;
|
| 480 |
+
flex-shrink: 0;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.btn {
|
| 484 |
+
border: 1px solid var(--border);
|
| 485 |
+
border-radius: 3px;
|
| 486 |
+
padding: 8px 12px;
|
| 487 |
+
background: var(--surface);
|
| 488 |
+
color: var(--text);
|
| 489 |
+
font-family: var(--mono);
|
| 490 |
+
font-size: 11px;
|
| 491 |
+
font-weight: 500;
|
| 492 |
+
cursor: pointer;
|
| 493 |
+
letter-spacing: 0.3px;
|
| 494 |
+
transition: background 0.1s, border-color 0.1s;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
.btn:hover:not(:disabled) {
|
| 498 |
+
background: var(--border-lg);
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.btn:disabled {
|
| 502 |
+
opacity: 0.35;
|
| 503 |
+
cursor: default;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.btn-danger {
|
| 507 |
+
color: var(--danger);
|
| 508 |
+
border-color: var(--danger);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.btn-danger:hover:not(:disabled) {
|
| 512 |
+
background: var(--danger-dim);
|
| 513 |
+
}
|
| 514 |
+
</style>
|
| 515 |
+
</head>
|
| 516 |
+
|
| 517 |
+
<body data-theme="light">
|
| 518 |
+
|
| 519 |
+
<div class="panel" id="panel">
|
| 520 |
+
|
| 521 |
+
<!-- Sticky topbar -->
|
| 522 |
+
<div class="topbar">
|
| 523 |
+
<div class="brand">
|
| 524 |
+
<div class="brand-mark">
|
| 525 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
| 526 |
+
stroke-linejoin="round">
|
| 527 |
+
<polygon
|
| 528 |
+
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
|
| 529 |
+
</svg>
|
| 530 |
+
</div>
|
| 531 |
+
<span class="brand-name">RADAI</span>
|
| 532 |
+
<div class="brand-sep"></div>
|
| 533 |
+
<span class="brand-sub">FORENSIC ANALYSIS</span>
|
| 534 |
+
</div>
|
| 535 |
+
<div class="topbar-right">
|
| 536 |
+
<div class="topbar-chip" id="statusChip">IDLE</div>
|
| 537 |
+
<button class="theme-toggle" id="themeBtn">DARK</button>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
<!-- Loading bar (hidden by default, shown during initial load) -->
|
| 542 |
+
<div class="loading-bar" id="loadingBar" style="display:none"></div>
|
| 543 |
+
|
| 544 |
+
<!-- Main content -->
|
| 545 |
+
<div class="content">
|
| 546 |
+
|
| 547 |
+
<!-- Action buttons -->
|
| 548 |
+
<div class="btn-row">
|
| 549 |
+
<button class="btn" id="scanBtn">SCAN IMAGES</button>
|
| 550 |
+
<button class="btn btn-danger" id="stopBtn" disabled>STOP</button>
|
| 551 |
+
</div>
|
| 552 |
+
|
| 553 |
+
<!-- Empty state -->
|
| 554 |
+
<div id="emptyState">
|
| 555 |
+
<div class="empty">
|
| 556 |
+
<div class="empty-icon">
|
| 557 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
| 558 |
+
<circle cx="11" cy="11" r="8" />
|
| 559 |
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
| 560 |
+
</svg>
|
| 561 |
+
</div>
|
| 562 |
+
<div class="empty-title">No scan data</div>
|
| 563 |
+
<div class="empty-desc">Click "SCAN IMAGES" to analyze the current webpage for AI-generated content.</div>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
|
| 567 |
+
<!-- Data sections -->
|
| 568 |
+
<div id="dataContainer">
|
| 569 |
+
|
| 570 |
+
<!-- Stopped banner (shown only when scan was stopped) -->
|
| 571 |
+
<div class="stopped-banner" id="stoppedBanner" style="display:none">
|
| 572 |
+
SCAN INTERRUPTED — incomplete data. Run a full scan for complete results.
|
| 573 |
+
</div>
|
| 574 |
+
|
| 575 |
+
<!-- Verdict -->
|
| 576 |
+
<div class="verdict-card">
|
| 577 |
+
<div class="verdict-top">
|
| 578 |
+
<div>
|
| 579 |
+
<div class="verdict-label" id="verdictLabel">—</div>
|
| 580 |
+
<div class="verdict-desc" id="verdictDesc" style="white-space: pre-line;">—</div>
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
|
| 584 |
+
<div class="risk-bar-wrap">
|
| 585 |
+
<div class="risk-bar-header">
|
| 586 |
+
<span>Risk score</span>
|
| 587 |
+
<span id="riskBarLabel">—</span>
|
| 588 |
+
</div>
|
| 589 |
+
<div class="risk-bar-track">
|
| 590 |
+
<div class="risk-bar-fill" id="riskBarFill"></div>
|
| 591 |
+
</div>
|
| 592 |
+
</div>
|
| 593 |
+
|
| 594 |
+
<div class="status-bar">
|
| 595 |
+
<div class="status-dot" id="statusDot"></div>
|
| 596 |
+
<div class="status-text" id="statusText">—</div>
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
|
| 600 |
+
<!-- How it works -->
|
| 601 |
+
<div class="how-it-works-card" id="howItWorks">
|
| 602 |
+
<div class="hiw-header">
|
| 603 |
+
<div class="section-label">How detection works</div>
|
| 604 |
+
</div>
|
| 605 |
+
<div class="hiw-body">
|
| 606 |
+
<div class="hiw-text">
|
| 607 |
+
Our system utilizes a dual-model ensemble to assess images for forensic anomalies:
|
| 608 |
+
</div>
|
| 609 |
+
<ul class="hiw-list">
|
| 610 |
+
<li><strong>Vision-Language Model</strong> (OpenCLIP) for broad semantic and texture analysis</li>
|
| 611 |
+
<li><strong>Secondary CNN Model</strong> (ConvNeXt) for deep feature pattern matching</li>
|
| 612 |
+
</ul>
|
| 613 |
+
<div class="hiw-text" style="margin-top: 10px;">
|
| 614 |
+
The algorithms flag synthetic origins by detecting subtle, generated inconsistencies in: <strong>texture
|
| 615 |
+
frequencies, lighting coherence, and spatial composition.</strong>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
</div>
|
| 619 |
+
|
| 620 |
+
</div><!-- /dataContainer -->
|
| 621 |
+
|
| 622 |
+
</div><!-- /content -->
|
| 623 |
+
|
| 624 |
+
<!-- Footer -->
|
| 625 |
+
<div class="panel-footer">
|
| 626 |
+
<div class="footer-left" id="footerLeft">RADAI v2.0</div>
|
| 627 |
+
<div class="footer-right" id="footerRight">—</div>
|
| 628 |
+
</div>
|
| 629 |
+
|
| 630 |
+
</div><!-- /panel -->
|
| 631 |
+
|
| 632 |
+
<script src="sidepanel.js"></script>
|
| 633 |
+
</body>
|
| 634 |
+
|
| 635 |
+
</html>
|
extension/sidepanel.js
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ── RADAI · sidepanel.js ──────────────────────────────────────────────
|
| 2 |
+
//
|
| 3 |
+
// Architecture:
|
| 4 |
+
// • Loads latest scan result from background via GET_SCAN_RESULT message
|
| 5 |
+
// • Also reads the stored screenshot from chrome.storage.local for heatmap
|
| 6 |
+
// • Heatmap: patch-based pixel analysis (frequency variance, luminance
|
| 7 |
+
// deviation, edge density per cell) rendered to a <canvas> element
|
| 8 |
+
// • Re-loads on window focus (user ran a new scan)
|
| 9 |
+
// • No state held in globals that could stale; every render call is fresh
|
| 10 |
+
//
|
| 11 |
+
// Bug fixes vs. previous version:
|
| 12 |
+
// • Background GET_SCAN_RESULT now has a 5s timeout guard
|
| 13 |
+
// • Canvas is cleared before each render (no ghost overlays)
|
| 14 |
+
// • All DOM refs validated before use
|
| 15 |
+
// • rAF used for canvas draw to avoid blocking the main thread
|
| 16 |
+
// ─────────────────────────────────────────────────────────────────────────
|
| 17 |
+
|
| 18 |
+
"use strict";
|
| 19 |
+
|
| 20 |
+
// ── DOM refs ──────────────────────────────────────────────────────────────
|
| 21 |
+
const $ = (id) => document.getElementById(id);
|
| 22 |
+
|
| 23 |
+
const statusChip = $("statusChip");
|
| 24 |
+
const loadingBar = $("loadingBar");
|
| 25 |
+
const emptyState = $("emptyState");
|
| 26 |
+
const dataContainer = $("dataContainer");
|
| 27 |
+
const stoppedBanner = $("stoppedBanner");
|
| 28 |
+
|
| 29 |
+
const verdictLabel = $("verdictLabel");
|
| 30 |
+
const verdictDesc = $("verdictDesc");
|
| 31 |
+
const riskBarFill = $("riskBarFill");
|
| 32 |
+
const riskBarLabel = $("riskBarLabel");
|
| 33 |
+
const statusDot = $("statusDot");
|
| 34 |
+
const statusText = $("statusText");
|
| 35 |
+
|
| 36 |
+
const footerLeft = $("footerLeft");
|
| 37 |
+
const footerRight = $("footerRight");
|
| 38 |
+
const themeBtn = $("themeBtn");
|
| 39 |
+
const scanBtn = $("scanBtn");
|
| 40 |
+
const stopBtn = $("stopBtn");
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
// ── UI state functions ────────────────────────────────────────────────────
|
| 45 |
+
|
| 46 |
+
function showLoading() {
|
| 47 |
+
loadingBar.style.display = "block";
|
| 48 |
+
statusChip.textContent = "LOADING";
|
| 49 |
+
statusChip.style.color = "";
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
function hideLoading() {
|
| 53 |
+
loadingBar.style.display = "none";
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function showEmpty() {
|
| 57 |
+
hideLoading();
|
| 58 |
+
emptyState.classList.remove("hidden");
|
| 59 |
+
dataContainer.classList.remove("visible");
|
| 60 |
+
statusChip.textContent = "READY";
|
| 61 |
+
statusChip.style.color = "";
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
function showStopped(result = {}) {
|
| 65 |
+
hideLoading();
|
| 66 |
+
emptyState.classList.add("hidden");
|
| 67 |
+
dataContainer.classList.add("visible");
|
| 68 |
+
stoppedBanner.style.display = "block";
|
| 69 |
+
|
| 70 |
+
statusChip.textContent = "STOPPED";
|
| 71 |
+
statusChip.style.color = "var(--warn)";
|
| 72 |
+
|
| 73 |
+
const scanned = result.count || 0;
|
| 74 |
+
const total = result.total || 0;
|
| 75 |
+
const flagged = result.aiCount || 0;
|
| 76 |
+
const risk = result.risk || 0;
|
| 77 |
+
|
| 78 |
+
verdictLabel.textContent = "Stopped (incomplete scan)";
|
| 79 |
+
verdictLabel.style.color = "var(--warn)";
|
| 80 |
+
|
| 81 |
+
verdictDesc.textContent = `${scanned} / ${total} images scanned\n${flagged} flagged as AI\n~${risk}% of scanned images appear AI-generated`;
|
| 82 |
+
|
| 83 |
+
riskBarFill.style.width = `${risk}%`;
|
| 84 |
+
riskBarFill.style.background = "var(--warn)";
|
| 85 |
+
riskBarLabel.textContent = `${risk}%`;
|
| 86 |
+
|
| 87 |
+
statusDot.style.background = "var(--warn)";
|
| 88 |
+
statusText.textContent = "SCAN STOPPED BY USER";
|
| 89 |
+
|
| 90 |
+
footerRight.textContent = "Partial scan";
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function showResult(result) {
|
| 94 |
+
hideLoading();
|
| 95 |
+
emptyState.classList.add("hidden");
|
| 96 |
+
dataContainer.classList.add("visible");
|
| 97 |
+
stoppedBanner.style.display = "none";
|
| 98 |
+
|
| 99 |
+
const risk = typeof result.risk === "number" ? result.risk : 0;
|
| 100 |
+
const count = typeof result.count === "number" ? result.count : 0;
|
| 101 |
+
const confidence = result.rawConfidence || (risk / 100);
|
| 102 |
+
const timestamp = result.timestamp;
|
| 103 |
+
|
| 104 |
+
// Colour swatch for risk
|
| 105 |
+
const riskColor =
|
| 106 |
+
risk > 60 ? "var(--danger)" :
|
| 107 |
+
risk > 30 ? "var(--warn)" : "var(--ok)";
|
| 108 |
+
|
| 109 |
+
// Status chip
|
| 110 |
+
statusChip.textContent = risk > 60 ? "HIGH RISK" : risk > 30 ? "MODERATE" : "CLEAN";
|
| 111 |
+
statusChip.style.color = riskColor;
|
| 112 |
+
|
| 113 |
+
if (count === 0) {
|
| 114 |
+
verdictLabel.textContent = "No Images";
|
| 115 |
+
verdictLabel.style.color = riskColor;
|
| 116 |
+
verdictDesc.textContent = "No images found on this page.";
|
| 117 |
+
} else {
|
| 118 |
+
// Verdict
|
| 119 |
+
verdictLabel.textContent = risk > 60 ? "AI-Generated" : risk > 30 ? "Suspicious" : "Likely Real";
|
| 120 |
+
verdictLabel.style.color = riskColor;
|
| 121 |
+
const flagged = Math.round((risk / 100) * count);
|
| 122 |
+
verdictDesc.textContent = `${count} images scanned • ${flagged} flagged as AI\n${risk}% of images appear AI-generated`;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// Risk bar
|
| 126 |
+
riskBarFill.style.width = `${risk}%`;
|
| 127 |
+
riskBarFill.style.background = riskColor;
|
| 128 |
+
riskBarLabel.textContent = `${risk}%`;
|
| 129 |
+
|
| 130 |
+
// Status row
|
| 131 |
+
statusDot.style.background = riskColor;
|
| 132 |
+
statusText.textContent =
|
| 133 |
+
risk > 60 ? `HIGH CONFIDENCE AI · ${count} image${count !== 1 ? "s" : ""} processed` :
|
| 134 |
+
risk > 30 ? `MODERATE SIGNAL · ${count} image${count !== 1 ? "s" : ""} processed` :
|
| 135 |
+
`CLEAN · ${count} image${count !== 1 ? "s" : ""} processed`;
|
| 136 |
+
|
| 137 |
+
// Footer
|
| 138 |
+
if (timestamp) {
|
| 139 |
+
const d = new Date(timestamp);
|
| 140 |
+
footerRight.textContent =
|
| 141 |
+
`Scanned at ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
| 142 |
+
} else {
|
| 143 |
+
footerRight.textContent = `${count} image${count !== 1 ? "s" : ""} analysed`;
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
// ── Load data (now just initialized on empty) ──────────────────────────────
|
| 148 |
+
async function loadResult() {
|
| 149 |
+
showEmpty();
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// ── Re-load on focus ──────────────────────────────────────────────────────
|
| 153 |
+
window.addEventListener("focus", () => {
|
| 154 |
+
loadResult();
|
| 155 |
+
});
|
| 156 |
+
|
| 157 |
+
// ── Theme ─────────────────────────────────────────────────────────────────
|
| 158 |
+
function applyTheme(theme) {
|
| 159 |
+
document.documentElement.setAttribute("data-theme", theme);
|
| 160 |
+
themeBtn.textContent = theme === "dark" ? "LIGHT" : "DARK";
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function initTheme() {
|
| 164 |
+
try {
|
| 165 |
+
chrome.storage.local.get("theme", (res) => {
|
| 166 |
+
void chrome.runtime.lastError;
|
| 167 |
+
const t = res?.theme ||
|
| 168 |
+
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
| 169 |
+
applyTheme(t);
|
| 170 |
+
});
|
| 171 |
+
} catch {
|
| 172 |
+
applyTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
themeBtn.addEventListener("click", () => {
|
| 177 |
+
const cur = document.documentElement.getAttribute("data-theme") || "light";
|
| 178 |
+
const next = cur === "dark" ? "light" : "dark";
|
| 179 |
+
applyTheme(next);
|
| 180 |
+
try { chrome.storage.local.set({ theme: next }); } catch { /* ignore */ }
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
// ── Global scan coordination ────────────────────────────────────────────────
|
| 184 |
+
let isScanRunning = false;
|
| 185 |
+
function setScanButtons(scanning) {
|
| 186 |
+
if (!scanBtn) return;
|
| 187 |
+
scanBtn.disabled = scanning;
|
| 188 |
+
scanBtn.textContent = scanning ? "SCANNING..." : "SCAN IMAGES";
|
| 189 |
+
stopBtn.disabled = !scanning;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
async function startScan() {
|
| 193 |
+
if (isScanRunning) return;
|
| 194 |
+
|
| 195 |
+
console.log("SCAN CLICKED");
|
| 196 |
+
|
| 197 |
+
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
| 198 |
+
const tab = tabs[0];
|
| 199 |
+
if (!tab) return;
|
| 200 |
+
if (!tab.url || tab.url.startsWith("chrome://") || tab.url.startsWith("chrome-extension://")) {
|
| 201 |
+
showEmpty(); // Not allowed
|
| 202 |
+
return;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
isScanRunning = true;
|
| 206 |
+
setScanButtons(true);
|
| 207 |
+
|
| 208 |
+
// Reset UI before scan
|
| 209 |
+
showLoading();
|
| 210 |
+
statusChip.textContent = "SCANNING...";
|
| 211 |
+
verdictLabel.textContent = "—";
|
| 212 |
+
verdictLabel.style.color = "inherit";
|
| 213 |
+
verdictDesc.textContent = "—";
|
| 214 |
+
riskBarFill.style.width = "0%";
|
| 215 |
+
riskBarLabel.textContent = "—";
|
| 216 |
+
statusText.textContent = "—";
|
| 217 |
+
|
| 218 |
+
let screenshot = null;
|
| 219 |
+
try {
|
| 220 |
+
screenshot = await chrome.tabs.captureVisibleTab(null, { format: "png" });
|
| 221 |
+
} catch (e) {
|
| 222 |
+
console.log("Screenshot failed (could be context limit)", e);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
console.log("Sending action: SCAN_IMAGES to content.js on tab:", tab.id);
|
| 226 |
+
|
| 227 |
+
chrome.tabs.sendMessage(tab.id, { action: "SCAN_IMAGES", screenshot }, (response) => {
|
| 228 |
+
isScanRunning = false;
|
| 229 |
+
setScanButtons(false);
|
| 230 |
+
|
| 231 |
+
if (chrome.runtime.lastError) {
|
| 232 |
+
console.warn("No response from content script. Error:", chrome.runtime.lastError.message);
|
| 233 |
+
showStopped();
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
if (response) {
|
| 238 |
+
console.log("Response:", response);
|
| 239 |
+
if (response.stopped) {
|
| 240 |
+
showStopped();
|
| 241 |
+
} else if (response.done) {
|
| 242 |
+
showResult(response);
|
| 243 |
+
} else {
|
| 244 |
+
showEmpty();
|
| 245 |
+
}
|
| 246 |
+
} else {
|
| 247 |
+
showEmpty();
|
| 248 |
+
}
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
async function stopScan() {
|
| 253 |
+
console.log("STOP CLICKED");
|
| 254 |
+
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
| 255 |
+
const tab = tabs[0];
|
| 256 |
+
if (tab) {
|
| 257 |
+
chrome.tabs.sendMessage(tab.id, { action: "STOP_SCAN" });
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
if (scanBtn) scanBtn.addEventListener("click", startScan);
|
| 262 |
+
if (stopBtn) stopBtn.addEventListener("click", stopScan);
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
// ── Boot ──────────────────────────────────────────────────────────────────
|
| 267 |
+
initTheme();
|
| 268 |
+
loadResult();
|
models/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
models/convnext_forensic_head.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3c63b47baf3dce549d8100b76330ab16e33618147e9f4551f2454117424c213d
|
| 3 |
+
size 2104051
|
models/convnext_training_log.csv
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
epoch,val_acc,train_loss
|
| 2 |
+
1,0.9625,0.36604552464166157
|
| 3 |
+
2,0.9783333333333334,0.11480749809476132
|
| 4 |
+
3,0.9866666666666667,0.06741061826791842
|
| 5 |
+
4,0.99,0.04732492169531438
|
| 6 |
+
5,0.9883333333333333,0.03646433481972822
|
models/openclip_forensic_head.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:35c8d7b1c89f8d273c9102043eac579bd3e220746f4858b325b7196c7b77264c
|
| 3 |
+
size 1579679
|
models/training_log.csv
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
epoch,train_loss,train_acc,val_loss,val_acc
|
| 2 |
+
1,0.18460586362296855,0.9287869704236611,0.11360406280606986,0.95745
|
| 3 |
+
2,0.12776405832496807,0.9513489208633094,0.1104536650210619,0.95815
|
| 4 |
+
3,0.10373234867592893,0.9604216626698641,0.09013852900639176,0.96635
|
| 5 |
+
4,0.08993887702157051,0.9658772981614708,0.0889575471073389,0.9681
|
| 6 |
+
5,0.07703082630736217,0.9714128697042366,0.09260836776420474,0.96485
|
| 7 |
+
6,0.06759887119140172,0.9743605115907275,0.09099651588853448,0.9658
|
| 8 |
+
7,0.05909571438406855,0.977847721822542,0.08872529532201588,0.96795
|
| 9 |
+
8,0.051992759698007616,0.9802458033573141,0.07628220746666194,0.9712
|
| 10 |
+
9,0.04654393878499227,0.9828836930455636,0.09181491279350594,0.96745
|
| 11 |
+
10,0.040898888357765555,0.9844524380495604,0.0927946492746938,0.96735
|
requirements.txt
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
annotated-doc==0.0.4
|
| 2 |
+
anyio==4.13.0
|
| 3 |
+
blinker==1.9.0
|
| 4 |
+
certifi==2026.2.25
|
| 5 |
+
charset-normalizer==3.4.7
|
| 6 |
+
click==8.3.2
|
| 7 |
+
contourpy==1.3.3
|
| 8 |
+
cycler==0.12.1
|
| 9 |
+
dotenv==0.9.9
|
| 10 |
+
filelock==3.25.2
|
| 11 |
+
Flask==3.1.3
|
| 12 |
+
flask-cors==6.0.2
|
| 13 |
+
fonttools==4.62.1
|
| 14 |
+
fsspec==2026.3.0
|
| 15 |
+
ftfy==6.3.1
|
| 16 |
+
h11==0.16.0
|
| 17 |
+
hf-xet==1.4.3
|
| 18 |
+
httpcore==1.0.9
|
| 19 |
+
httpx==0.28.1
|
| 20 |
+
huggingface_hub==1.10.1
|
| 21 |
+
idna==3.11
|
| 22 |
+
itsdangerous==2.2.0
|
| 23 |
+
Jinja2==3.1.6
|
| 24 |
+
joblib==1.5.3
|
| 25 |
+
kiwisolver==1.5.0
|
| 26 |
+
markdown-it-py==4.0.0
|
| 27 |
+
MarkupSafe==3.0.3
|
| 28 |
+
matplotlib==3.10.8
|
| 29 |
+
mdurl==0.1.2
|
| 30 |
+
mpmath==1.3.0
|
| 31 |
+
networkx==3.6.1
|
| 32 |
+
numpy==2.4.4
|
| 33 |
+
open_clip_torch==3.3.0
|
| 34 |
+
packaging==26.0
|
| 35 |
+
pandas==3.0.2
|
| 36 |
+
pillow==12.2.0
|
| 37 |
+
Pygments==2.20.0
|
| 38 |
+
pyparsing==3.3.2
|
| 39 |
+
python-dateutil==2.9.0.post0
|
| 40 |
+
python-dotenv==1.2.2
|
| 41 |
+
PyYAML==6.0.3
|
| 42 |
+
regex==2026.4.4
|
| 43 |
+
requests==2.33.1
|
| 44 |
+
rich==14.3.3
|
| 45 |
+
safetensors==0.7.0
|
| 46 |
+
scikit-learn==1.8.0
|
| 47 |
+
scipy==1.17.1
|
| 48 |
+
seaborn==0.13.2
|
| 49 |
+
setuptools==81.0.0
|
| 50 |
+
shellingham==1.5.4
|
| 51 |
+
six==1.17.0
|
| 52 |
+
sympy==1.14.0
|
| 53 |
+
threadpoolctl==3.6.0
|
| 54 |
+
timm==1.0.26
|
| 55 |
+
tokenizers==0.22.2
|
| 56 |
+
torch==2.11.0
|
| 57 |
+
torchvision==0.26.0
|
| 58 |
+
tqdm==4.67.3
|
| 59 |
+
transformers==5.5.4
|
| 60 |
+
typer==0.24.1
|
| 61 |
+
typing_extensions==4.15.0
|
| 62 |
+
urllib3==2.6.3
|
| 63 |
+
wcwidth==0.6.0
|
| 64 |
+
Werkzeug==3.1.8
|
test.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
from torchvision import transforms, models
|
| 4 |
+
import open_clip
|
| 5 |
+
from PIL import Image, ImageFilter
|
| 6 |
+
import numpy as np
|
| 7 |
+
import os
|
| 8 |
+
|
| 9 |
+
# --- 1. SETUP & DEVICE ---
|
| 10 |
+
DEVICE = torch.device("mps") if torch.backends.mps.is_available() else torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 11 |
+
print(f"Using device: {DEVICE}")
|
| 12 |
+
|
| 13 |
+
# --- 2. LOAD MODELS ---
|
| 14 |
+
|
| 15 |
+
# A. Load openclip (ViT-L-14)
|
| 16 |
+
print("Loading openclip...")
|
| 17 |
+
openclip_model, _, openclip_preprocess = open_clip.create_model_and_transforms(
|
| 18 |
+
'ViT-L-14', pretrained='datacomp_xl_s13b_b90k'
|
| 19 |
+
)
|
| 20 |
+
openclip_model.to(DEVICE)
|
| 21 |
+
|
| 22 |
+
# Define your openclip Forensic Head Architecture (matches your training)
|
| 23 |
+
class openclipHead(nn.Module):
|
| 24 |
+
def __init__(self, input_dim):
|
| 25 |
+
super().__init__()
|
| 26 |
+
self.net = nn.Sequential(
|
| 27 |
+
nn.Linear(input_dim, 512),
|
| 28 |
+
nn.ReLU(),
|
| 29 |
+
nn.Dropout(0.3),
|
| 30 |
+
nn.Linear(512, 1)
|
| 31 |
+
)
|
| 32 |
+
def forward(self, x): return self.net(x)
|
| 33 |
+
|
| 34 |
+
# Load openclip Weights
|
| 35 |
+
openclip_head = openclipHead(input_dim=768).to(DEVICE)
|
| 36 |
+
openclip_head.load_state_dict(torch.load('models/openclip_forensic_head.pth', map_location=DEVICE))
|
| 37 |
+
openclip_head.eval()
|
| 38 |
+
|
| 39 |
+
# B. Load ConvNeXt-Base
|
| 40 |
+
print("Loading ConvNeXt...")
|
| 41 |
+
cn_backbone = models.convnext_base(weights=None) # Architecture only
|
| 42 |
+
cn_backbone.to(DEVICE)
|
| 43 |
+
cn_backbone.eval()
|
| 44 |
+
|
| 45 |
+
class ConvNextHead(nn.Module):
|
| 46 |
+
def __init__(self, input_dim):
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.net = nn.Sequential(
|
| 49 |
+
nn.Linear(input_dim, 512),
|
| 50 |
+
nn.ReLU(),
|
| 51 |
+
nn.Dropout(0.3),
|
| 52 |
+
nn.Linear(512, 1)
|
| 53 |
+
)
|
| 54 |
+
def forward(self, x): return self.net(x)
|
| 55 |
+
|
| 56 |
+
cn_head = ConvNextHead(input_dim=1024).to(DEVICE)
|
| 57 |
+
cn_head.load_state_dict(torch.load('models/convnext_forensic_head.pth', map_location=DEVICE))
|
| 58 |
+
cn_head.eval()
|
| 59 |
+
|
| 60 |
+
# ConvNext Preprocessing (Standard ImageNet)
|
| 61 |
+
cn_preprocess = transforms.Compose([
|
| 62 |
+
transforms.Resize(256),
|
| 63 |
+
transforms.CenterCrop(224),
|
| 64 |
+
transforms.ToTensor(),
|
| 65 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
|
| 66 |
+
])
|
| 67 |
+
|
| 68 |
+
# --- 3. FEATURE EXTRACTION (Heuristics) ---
|
| 69 |
+
|
| 70 |
+
def extract_simple_features(image_path):
|
| 71 |
+
img = Image.open(image_path).convert('RGB')
|
| 72 |
+
img_array = np.array(img) / 255.0
|
| 73 |
+
|
| 74 |
+
edges = np.abs(np.diff(np.mean(img_array, axis=2), axis=0)).mean() + \
|
| 75 |
+
np.abs(np.diff(np.mean(img_array, axis=2), axis=1)).mean()
|
| 76 |
+
|
| 77 |
+
img_smooth = np.array(img.filter(ImageFilter.GaussianBlur(2))) / 255.0
|
| 78 |
+
noise = np.mean((img_array - img_smooth) ** 2) * 1000
|
| 79 |
+
|
| 80 |
+
return {
|
| 81 |
+
'noise_level': noise,
|
| 82 |
+
'edge_density': edges,
|
| 83 |
+
'is_too_clean': (noise < 0.05 and edges < 0.12) # Adjusted thresholds
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
# --- 4. THE ENSEMBLE INFERENCE ---
|
| 87 |
+
|
| 88 |
+
def run_ensemble(image_path):
|
| 89 |
+
img = Image.open(image_path).convert('RGB')
|
| 90 |
+
|
| 91 |
+
# openclip Score
|
| 92 |
+
img_openclip = openclip_preprocess(img).unsqueeze(0).to(DEVICE)
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
sig_feat = openclip_model.encode_image(img_openclip)
|
| 95 |
+
sig_feat /= sig_feat.norm(dim=-1, keepdim=True)
|
| 96 |
+
sig_logit = openclip_head(sig_feat)
|
| 97 |
+
prob_openclip = torch.sigmoid(sig_logit).item()
|
| 98 |
+
|
| 99 |
+
# ConvNeXt Score
|
| 100 |
+
img_cn = cn_preprocess(img).unsqueeze(0).to(DEVICE)
|
| 101 |
+
with torch.no_grad():
|
| 102 |
+
feat = cn_backbone.features(img_cn)
|
| 103 |
+
feat = cn_backbone.avgpool(feat)
|
| 104 |
+
feat = torch.flatten(feat, 1)
|
| 105 |
+
cn_logit = cn_head(feat)
|
| 106 |
+
prob_cn = torch.sigmoid(cn_logit).item()
|
| 107 |
+
|
| 108 |
+
# Average the two for the "Raw Ensemble Score"
|
| 109 |
+
raw_ensemble_score = (prob_openclip + prob_cn) / 2
|
| 110 |
+
|
| 111 |
+
# Calibration
|
| 112 |
+
features = extract_simple_features(image_path)
|
| 113 |
+
if features['is_too_clean']:
|
| 114 |
+
calibrated_score = raw_ensemble_score * 0.55 # 45% discount for product shots
|
| 115 |
+
reason = "Clean product-shot detected. Reducing probability."
|
| 116 |
+
else:
|
| 117 |
+
calibrated_score = raw_ensemble_score
|
| 118 |
+
reason = "Standard analysis applied."
|
| 119 |
+
|
| 120 |
+
return {
|
| 121 |
+
'openclip_score': prob_openclip,
|
| 122 |
+
'convnext_score': prob_cn,
|
| 123 |
+
'raw_ensemble': raw_ensemble_score,
|
| 124 |
+
'calibrated': min(calibrated_score, 0.95),
|
| 125 |
+
'reason': reason,
|
| 126 |
+
'features': features
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# --- 5. TEST IT ---
|
| 130 |
+
test_image = "/Users/rishitbaitule/Downloads/b.jpg" # Update this path!
|
| 131 |
+
|
| 132 |
+
if os.path.exists(test_image):
|
| 133 |
+
results = run_ensemble(test_image)
|
| 134 |
+
|
| 135 |
+
print("-" * 30)
|
| 136 |
+
print(f"Individual openclip: {results['openclip_score']:.2%}")
|
| 137 |
+
print(f"Individual ConvNeXt: {results['convnext_score']:.2%}")
|
| 138 |
+
print("-" * 30)
|
| 139 |
+
print(f"ENSEMBLE RAW SCORE: {results['raw_ensemble']:.2%}")
|
| 140 |
+
print(f"CALIBRATED SCORE: {results['calibrated']:.2%}")
|
| 141 |
+
print(f"REASON: {results['reason']}")
|
| 142 |
+
print("-" * 30)
|
| 143 |
+
else:
|
| 144 |
+
print("Image not found. Please check test_image path.")
|
train_convnext.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.optim as optim
|
| 4 |
+
from torch.utils.data import DataLoader
|
| 5 |
+
from torchvision import transforms, models
|
| 6 |
+
from datasets import load_dataset
|
| 7 |
+
from huggingface_hub import login
|
| 8 |
+
from kaggle_secrets import UserSecretsClient
|
| 9 |
+
from PIL import Image
|
| 10 |
+
import os
|
| 11 |
+
import random
|
| 12 |
+
import pandas as pd
|
| 13 |
+
from tqdm.auto import tqdm
|
| 14 |
+
|
| 15 |
+
# config
|
| 16 |
+
user_secrets = UserSecretsClient()
|
| 17 |
+
try:
|
| 18 |
+
hf_token = user_secrets.get_secret("HF_TOKEN")
|
| 19 |
+
login(token=hf_token)
|
| 20 |
+
except:
|
| 21 |
+
print("HF_TOKEN not found in Secrets. Ensure you added it!")
|
| 22 |
+
|
| 23 |
+
KAGLE_REAL_PATH = "/kaggle/input/datasets/matthewjansen/unsplash-lite-5k-colorization/train/color"
|
| 24 |
+
HF_AI_DATASET = "Rapidata/Flux_SD3_MJ_Dalle_Human_Alignment_Dataset"
|
| 25 |
+
TARGET_SHARDS = ["train_0001", "train_0002", "train_0003", "train_0004"]
|
| 26 |
+
SAVE_PATH = "/kaggle/working/convnext_forensic_head.pth"
|
| 27 |
+
LOG_PATH = "/kaggle/working/convnext_training_log.csv"
|
| 28 |
+
|
| 29 |
+
# training params
|
| 30 |
+
BATCH_SIZE = 32
|
| 31 |
+
EPOCHS = 5
|
| 32 |
+
LR = 1e-4
|
| 33 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 34 |
+
IMG_SIZE = (224, 224)
|
| 35 |
+
|
| 36 |
+
final_data = []
|
| 37 |
+
print(f"Streaming AI shards: {TARGET_SHARDS}")
|
| 38 |
+
for shard in TARGET_SHARDS:
|
| 39 |
+
shard_stream = load_dataset(HF_AI_DATASET, split=shard, streaming=True)
|
| 40 |
+
for item in tqdm(shard_stream, total=1000, desc=f"AI Shard {shard}"):
|
| 41 |
+
img = item["image1"].convert("RGB").resize(IMG_SIZE)
|
| 42 |
+
final_data.append({"image": img, "label": 1})
|
| 43 |
+
|
| 44 |
+
real_files = [os.path.join(KAGLE_REAL_PATH, f) for f in os.listdir(KAGLE_REAL_PATH)
|
| 45 |
+
if f.lower().endswith(('.jpg', '.png', '.jpeg'))]
|
| 46 |
+
random.shuffle(real_files)
|
| 47 |
+
|
| 48 |
+
print(f"Balancing with {len(final_data)} Real images")
|
| 49 |
+
for i in tqdm(range(min(len(final_data), len(real_files))), desc="Processing Real"):
|
| 50 |
+
try:
|
| 51 |
+
img = Image.open(real_files[i]).convert("RGB").resize(IMG_SIZE)
|
| 52 |
+
final_data.append({"image": img, "label": 0})
|
| 53 |
+
except: continue
|
| 54 |
+
|
| 55 |
+
random.shuffle(final_data)
|
| 56 |
+
split_idx = int(len(final_data) * 0.85)
|
| 57 |
+
train_list, val_list = final_data[:split_idx], final_data[split_idx:]
|
| 58 |
+
|
| 59 |
+
# model details
|
| 60 |
+
backbone = models.convnext_base(weights='IMAGENET1K_V1')
|
| 61 |
+
backbone = backbone.to(DEVICE)
|
| 62 |
+
for param in backbone.parameters():
|
| 63 |
+
param.requires_grad = False
|
| 64 |
+
backbone.eval()
|
| 65 |
+
|
| 66 |
+
class ForensicHead(nn.Module):
|
| 67 |
+
def __init__(self, input_dim):
|
| 68 |
+
super().__init__()
|
| 69 |
+
self.net = nn.Sequential(
|
| 70 |
+
nn.Linear(input_dim, 512),
|
| 71 |
+
nn.ReLU(),
|
| 72 |
+
nn.Dropout(0.3),
|
| 73 |
+
nn.Linear(512, 1)
|
| 74 |
+
)
|
| 75 |
+
def forward(self, x): return self.net(x)
|
| 76 |
+
|
| 77 |
+
feature_dim = backbone.classifier[2].in_features
|
| 78 |
+
head = ForensicHead(input_dim=feature_dim).to(DEVICE)
|
| 79 |
+
|
| 80 |
+
if torch.cuda.device_count() > 1:
|
| 81 |
+
print(f"Activating Dual-GPU Mode with {torch.cuda.device_count()} T4s")
|
| 82 |
+
head = nn.DataParallel(head)
|
| 83 |
+
backbone = nn.DataParallel(backbone)
|
| 84 |
+
|
| 85 |
+
# preprocessing
|
| 86 |
+
preprocess = transforms.Compose([
|
| 87 |
+
transforms.ToTensor(),
|
| 88 |
+
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
|
| 89 |
+
])
|
| 90 |
+
|
| 91 |
+
def collate_fn(batch):
|
| 92 |
+
imgs = torch.stack([preprocess(item['image']) for item in batch])
|
| 93 |
+
lbls = torch.tensor([item['label'] for item in batch]).float().view(-1, 1)
|
| 94 |
+
return imgs, lbls
|
| 95 |
+
|
| 96 |
+
train_loader = DataLoader(train_list, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)
|
| 97 |
+
val_loader = DataLoader(val_list, batch_size=BATCH_SIZE, collate_fn=collate_fn)
|
| 98 |
+
|
| 99 |
+
# training loop
|
| 100 |
+
optimizer = optim.Adam(head.parameters(), lr=LR)
|
| 101 |
+
criterion = nn.BCEWithLogitsLoss()
|
| 102 |
+
scaler = torch.amp.GradScaler('cuda')
|
| 103 |
+
|
| 104 |
+
best_acc, history = 0.0, []
|
| 105 |
+
|
| 106 |
+
for epoch in range(EPOCHS):
|
| 107 |
+
head.train()
|
| 108 |
+
train_loss = 0
|
| 109 |
+
pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{EPOCHS}")
|
| 110 |
+
|
| 111 |
+
for imgs, lbls in pbar:
|
| 112 |
+
imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)
|
| 113 |
+
optimizer.zero_grad()
|
| 114 |
+
|
| 115 |
+
with torch.amp.autocast('cuda'):
|
| 116 |
+
with torch.no_grad():
|
| 117 |
+
# Extract features handling DataParallel wrapper
|
| 118 |
+
if isinstance(backbone, nn.DataParallel):
|
| 119 |
+
feat = backbone.module.features(imgs)
|
| 120 |
+
feat = backbone.module.avgpool(feat)
|
| 121 |
+
else:
|
| 122 |
+
feat = backbone.features(imgs)
|
| 123 |
+
feat = backbone.avgpool(feat)
|
| 124 |
+
feat = torch.flatten(feat, 1)
|
| 125 |
+
|
| 126 |
+
logits = head(feat)
|
| 127 |
+
loss = criterion(logits, lbls)
|
| 128 |
+
|
| 129 |
+
scaler.scale(loss).backward()
|
| 130 |
+
scaler.step(optimizer)
|
| 131 |
+
scaler.update()
|
| 132 |
+
train_loss += loss.item()
|
| 133 |
+
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
| 134 |
+
|
| 135 |
+
# validation
|
| 136 |
+
head.eval()
|
| 137 |
+
val_correct = 0
|
| 138 |
+
with torch.no_grad():
|
| 139 |
+
for imgs, lbls in val_loader:
|
| 140 |
+
imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)
|
| 141 |
+
with torch.amp.autocast('cuda'):
|
| 142 |
+
if isinstance(backbone, nn.DataParallel):
|
| 143 |
+
feat = backbone.module.features(imgs)
|
| 144 |
+
feat = backbone.module.avgpool(feat)
|
| 145 |
+
else:
|
| 146 |
+
feat = backbone.features(imgs)
|
| 147 |
+
feat = backbone.avgpool(feat)
|
| 148 |
+
feat = torch.flatten(feat, 1)
|
| 149 |
+
|
| 150 |
+
logits = head(feat)
|
| 151 |
+
preds = (torch.sigmoid(logits) > 0.5).float()
|
| 152 |
+
|
| 153 |
+
val_correct += (preds == lbls).sum().item()
|
| 154 |
+
|
| 155 |
+
val_acc = val_correct / len(val_list)
|
| 156 |
+
avg_loss = train_loss / len(train_loader)
|
| 157 |
+
print(f"Epoch {epoch+1} | Loss: {avg_loss:.4f} | Val Acc: {val_acc:.4f}")
|
| 158 |
+
|
| 159 |
+
history.append({'epoch': epoch+1, 'val_acc': val_acc, 'train_loss': avg_loss})
|
| 160 |
+
pd.DataFrame(history).to_csv(LOG_PATH, index=False)
|
| 161 |
+
|
| 162 |
+
if val_acc > best_acc:
|
| 163 |
+
best_acc = val_acc
|
| 164 |
+
save_state = head.module.state_dict() if isinstance(head, nn.DataParallel) else head.state_dict()
|
| 165 |
+
torch.save(save_state, SAVE_PATH)
|
| 166 |
+
print("--> Best Model Saved!")
|
| 167 |
+
|
| 168 |
+
print(f"Training Complete. File saved: {SAVE_PATH}")
|
train_openclip.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.optim as optim
|
| 4 |
+
from torch.utils.data import DataLoader
|
| 5 |
+
from datasets import load_dataset
|
| 6 |
+
import open_clip
|
| 7 |
+
from tqdm.auto import tqdm
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from huggingface_hub import login
|
| 12 |
+
from kaggle_secrets import UserSecretsClient
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
user_secrets = UserSecretsClient()
|
| 16 |
+
hf_token = user_secrets.get_secret("HF_TOKEN")
|
| 17 |
+
login(token=hf_token)
|
| 18 |
+
print("Successfully logged into Hugging Face")
|
| 19 |
+
except Exception as e:
|
| 20 |
+
print("Warning: Could not find HF_TOKEN in Kaggle Secrets. Proceeding anonymously")
|
| 21 |
+
|
| 22 |
+
# config
|
| 23 |
+
KAGLE_REAL_PATH = "/kaggle/input/datasets/matthewjansen/unsplash-lite-5k-colorization/train/color"
|
| 24 |
+
HF_AI_DATASET = "Rapidata/Flux_SD3_MJ_Dalle_Human_Alignment_Dataset"
|
| 25 |
+
SAVE_PATH = "/kaggle/working/openclip_forensic_head.pth"
|
| 26 |
+
TARGET_SHARDS = ["train_0001", "train_0002", "train_0003", "train_0004"]
|
| 27 |
+
|
| 28 |
+
# params
|
| 29 |
+
BATCH_SIZE = 16
|
| 30 |
+
EPOCHS = 5
|
| 31 |
+
LR = 1e-4
|
| 32 |
+
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 33 |
+
IMG_SIZE = (224, 224)
|
| 34 |
+
|
| 35 |
+
# data loading with streaming
|
| 36 |
+
print(f"Streaming {len(TARGET_SHARDS)} shards from Hugging Face")
|
| 37 |
+
final_data = []
|
| 38 |
+
|
| 39 |
+
for shard in TARGET_SHARDS:
|
| 40 |
+
print(f"Opening stream for {shard}")
|
| 41 |
+
shard_stream = load_dataset(HF_AI_DATASET, split=shard, streaming=True)
|
| 42 |
+
|
| 43 |
+
for item in tqdm(shard_stream, total=1000, desc=f"Streaming {shard}"):
|
| 44 |
+
# Resize immediately to keep RAM usage low
|
| 45 |
+
img = item["image1"].convert("RGB").resize(IMG_SIZE)
|
| 46 |
+
final_data.append({
|
| 47 |
+
"image": img,
|
| 48 |
+
"label": 1
|
| 49 |
+
})
|
| 50 |
+
|
| 51 |
+
num_ai_images = len(final_data)
|
| 52 |
+
print(f"Total AI images collected: {num_ai_images}")
|
| 53 |
+
|
| 54 |
+
print("Loading Real Images from Kaggle")
|
| 55 |
+
real_images_list = [os.path.join(KAGLE_REAL_PATH, f) for f in os.listdir(KAGLE_REAL_PATH) if
|
| 56 |
+
f.endswith(('.jpg', '.jpeg', '.png'))]
|
| 57 |
+
random.shuffle(real_images_list)
|
| 58 |
+
|
| 59 |
+
print(f"Balancing dataset with {num_ai_images} Real images")
|
| 60 |
+
for i in tqdm(range(min(num_ai_images, len(real_images_list))), desc="Processing Real Images"):
|
| 61 |
+
path = real_images_list[i]
|
| 62 |
+
try:
|
| 63 |
+
img = Image.open(path).convert("RGB").resize(IMG_SIZE)
|
| 64 |
+
final_data.append({
|
| 65 |
+
"image": img,
|
| 66 |
+
"label": 0
|
| 67 |
+
})
|
| 68 |
+
except Exception as e:
|
| 69 |
+
continue
|
| 70 |
+
|
| 71 |
+
# shuffle split
|
| 72 |
+
random.seed(42)
|
| 73 |
+
random.shuffle(final_data)
|
| 74 |
+
|
| 75 |
+
split_idx = int(len(final_data) * 0.85)
|
| 76 |
+
train_list = final_data[:split_idx]
|
| 77 |
+
val_list = final_data[split_idx:]
|
| 78 |
+
|
| 79 |
+
print(f"Dataset prepared: Train size = {len(train_list)}, Val size = {len(val_list)}")
|
| 80 |
+
|
| 81 |
+
# model init
|
| 82 |
+
print(f"Initializing ViT-L-14 on {DEVICE}")
|
| 83 |
+
model, _, preprocess_val = open_clip.create_model_and_transforms(
|
| 84 |
+
'ViT-L-14',
|
| 85 |
+
pretrained='datacomp_xl_s13b_b90k'
|
| 86 |
+
)
|
| 87 |
+
model = model.to(DEVICE)
|
| 88 |
+
|
| 89 |
+
# freeze backbone
|
| 90 |
+
for param in model.parameters():
|
| 91 |
+
param.requires_grad = False
|
| 92 |
+
|
| 93 |
+
print("Detecting feature dimensions...")
|
| 94 |
+
with torch.no_grad():
|
| 95 |
+
dummy_input = torch.randn(1, 3, 224, 224).to(DEVICE)
|
| 96 |
+
dummy_feature = model.encode_image(dummy_input)
|
| 97 |
+
detected_dim = dummy_feature.shape[1]
|
| 98 |
+
print(f"Backbone output dimension: {detected_dim}")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class ForensicHead(nn.Module):
|
| 102 |
+
def __init__(self, input_dim):
|
| 103 |
+
super().__init__()
|
| 104 |
+
self.net = nn.Sequential(
|
| 105 |
+
nn.Linear(input_dim, 512),
|
| 106 |
+
nn.ReLU(),
|
| 107 |
+
nn.Dropout(0.3),
|
| 108 |
+
nn.Linear(512, 1),
|
| 109 |
+
nn.Sigmoid()
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def forward(self, x):
|
| 113 |
+
return self.net(x)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# Initialize head with detected dimension (768 for DataComp ViT-L-14)
|
| 117 |
+
head = ForensicHead(input_dim=detected_dim).to(DEVICE)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def collate_fn(batch):
|
| 121 |
+
images = [preprocess_val(item['image']) for item in batch]
|
| 122 |
+
labels = [item['label'] for item in batch]
|
| 123 |
+
return torch.stack(images), torch.tensor(labels).float().view(-1, 1)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
train_loader = DataLoader(train_list, batch_size=BATCH_SIZE, shuffle=True, collate_fn=collate_fn)
|
| 127 |
+
val_loader = DataLoader(val_list, batch_size=BATCH_SIZE, collate_fn=collate_fn)
|
| 128 |
+
|
| 129 |
+
# training loop
|
| 130 |
+
optimizer = optim.Adam(head.parameters(), lr=LR)
|
| 131 |
+
criterion = nn.BCELoss()
|
| 132 |
+
best_acc = 0.0
|
| 133 |
+
|
| 134 |
+
print(f"Starting training on {len(train_list)} images")
|
| 135 |
+
|
| 136 |
+
for epoch in range(EPOCHS):
|
| 137 |
+
head.train()
|
| 138 |
+
train_pbar = tqdm(train_loader, desc=f"Epoch {epoch + 1}/{EPOCHS} [Train]")
|
| 139 |
+
|
| 140 |
+
epoch_loss = 0
|
| 141 |
+
for imgs, lbls in train_pbar:
|
| 142 |
+
imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)
|
| 143 |
+
|
| 144 |
+
with torch.no_grad():
|
| 145 |
+
features = model.encode_image(imgs)
|
| 146 |
+
features /= features.norm(dim=-1, keepdim=True)
|
| 147 |
+
|
| 148 |
+
optimizer.zero_grad()
|
| 149 |
+
outputs = head(features)
|
| 150 |
+
loss = criterion(outputs, lbls)
|
| 151 |
+
loss.backward()
|
| 152 |
+
optimizer.step()
|
| 153 |
+
|
| 154 |
+
epoch_loss += loss.item()
|
| 155 |
+
train_pbar.set_postfix(loss=f"{loss.item():.4f}")
|
| 156 |
+
|
| 157 |
+
# validation
|
| 158 |
+
head.eval()
|
| 159 |
+
val_correct = 0
|
| 160 |
+
val_pbar = tqdm(val_loader, desc=f"Epoch {epoch + 1}/{EPOCHS} [Val]")
|
| 161 |
+
|
| 162 |
+
with torch.no_grad():
|
| 163 |
+
for imgs, lbls in val_pbar:
|
| 164 |
+
imgs, lbls = imgs.to(DEVICE), lbls.to(DEVICE)
|
| 165 |
+
feat = model.encode_image(imgs)
|
| 166 |
+
feat /= feat.norm(dim=-1, keepdim=True)
|
| 167 |
+
preds = (head(feat) > 0.5).float()
|
| 168 |
+
val_correct += (preds == lbls).sum().item()
|
| 169 |
+
|
| 170 |
+
val_acc = val_correct / len(val_list)
|
| 171 |
+
print(f"Epoch {epoch + 1} Results | Loss: {epoch_loss / len(train_loader):.4f} | Val Acc: {val_acc:.4f}")
|
| 172 |
+
|
| 173 |
+
if val_acc > best_acc:
|
| 174 |
+
best_acc = val_acc
|
| 175 |
+
torch.save(head.state_dict(), SAVE_PATH)
|
| 176 |
+
print(f"New best model saved with {val_acc:.4f} accuracy")
|
| 177 |
+
|
| 178 |
+
print(f"Training complete. Model saved in: {SAVE_PATH}")
|
visualise.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from PIL import Image
|
| 3 |
+
from transformers import AutoProcessor, openclipVisionModel
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
# Load your model
|
| 8 |
+
processor = AutoProcessor.from_pretrained("google/openclip-so400m-patch14-384")
|
| 9 |
+
model = openclipVisionModel.from_pretrained("google/openclip-so400m-patch14-384")
|
| 10 |
+
model.load_state_dict(torch.load("your_finetuned_openclip.pt"))
|
| 11 |
+
|
| 12 |
+
# Set up to get attention maps
|
| 13 |
+
model.eval()
|
| 14 |
+
model.vision_model.encoder.config.output_attentions = True
|
| 15 |
+
|
| 16 |
+
# Load image
|
| 17 |
+
img = Image.open("car_product_photo.jpg")
|
| 18 |
+
inputs = processor(images=img, return_tensors="pt")
|
| 19 |
+
|
| 20 |
+
# Forward pass WITH attention
|
| 21 |
+
with torch.no_grad():
|
| 22 |
+
outputs = model.vision_model(**inputs, output_attentions=True)
|
| 23 |
+
|
| 24 |
+
# Get attention weights from last layer
|
| 25 |
+
# Shape: (batch, num_heads, seq_len, seq_len)
|
| 26 |
+
attention_weights = outputs.attentions[-1]
|
| 27 |
+
|
| 28 |
+
# Average across heads and batch
|
| 29 |
+
attention_map = attention_weights[0].mean(dim=0) # (seq_len, seq_len)
|
| 30 |
+
|
| 31 |
+
# Reshape back to image space
|
| 32 |
+
# openclip uses patch embedding, so we need to reshape
|
| 33 |
+
H, W = 384, 384 # input image size
|
| 34 |
+
patch_size = 14
|
| 35 |
+
num_patches_h = H // patch_size # 27
|
| 36 |
+
num_patches_w = W // patch_size # 27
|
| 37 |
+
|
| 38 |
+
# Take the attention to the [CLS] token (first token)
|
| 39 |
+
cls_attention = attention_map[0, 1:] # Ignore self-attention to CLS
|
| 40 |
+
cls_attention = cls_attention.reshape(num_patches_h, num_patches_w)
|
| 41 |
+
|
| 42 |
+
# Upsample to image size
|
| 43 |
+
cls_attention_upsampled = torch.nn.functional.interpolate(
|
| 44 |
+
cls_attention.unsqueeze(0).unsqueeze(0),
|
| 45 |
+
size=(H, W),
|
| 46 |
+
mode='bilinear'
|
| 47 |
+
).squeeze()
|
| 48 |
+
|
| 49 |
+
# Normalize to 0-1
|
| 50 |
+
cls_attention_upsampled = (cls_attention_upsampled - cls_attention_upsampled.min()) / \
|
| 51 |
+
(cls_attention_upsampled.max() - cls_attention_upsampled.min())
|
| 52 |
+
|
| 53 |
+
return cls_attention_upsampled.numpy()
|
| 54 |
+
|
| 55 |
+
# Visualize
|
| 56 |
+
heatmap = get_attention_heatmap(img)
|
| 57 |
+
plt.imshow(img)
|
| 58 |
+
plt.imshow(heatmap, alpha=0.4, cmap='jet')
|
| 59 |
+
plt.show()
|