RageCoder2006 commited on
Commit
879d39c
·
0 Parent(s):

Clean deployment for Hugging Face

Browse files
.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
+ [![Status](https://img.shields.io/badge/Status-Working-success?style=for-the-badge)](https://github.com/How2Invade/extension-deepfake)
8
+ [![Model](https://img.shields.io/badge/Model-OpenCLIP2-black?style=for-the-badge)](https://github.com/How2Invade/extension-deepfake)
9
+ [![Backend](https://img.shields.io/badge/Backend-Flask-blue?style=for-the-badge)](https://flask.palletsprojects.com/)
10
+ [![Platform](https://img.shields.io/badge/Platform-Chrome-green?style=for-the-badge)](https://chrome.google.com/)
11
+ [![PyTorch](https://img.shields.io/badge/PyTorch-EE4C2C?style=for-the-badge&logo=pytorch&logoColor=white)](https://pytorch.org/)
12
+ [![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white)](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()