satyakimitra commited on
Commit
e7f1d57
·
0 Parent(s):

Initial commit: ImageScreenAI statistical image screening system

Browse files
.env.example ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ==============================================
2
+ # ImageScreenAI - Environment Configuration
3
+ # ==============================================
4
+
5
+ # Application
6
+ APP_NAME="ImageScreenAI"
7
+ VERSION="1.0.0"
8
+ DEBUG=False
9
+ LOG_LEVEL="INFO"
10
+
11
+ # Server Configuration
12
+ HOST="0.0.0.0"
13
+ PORT=7860
14
+ WORKERS=1
15
+
16
+ # File Processing
17
+ MAX_FILE_SIZE_MB=10
18
+ MAX_BATCH_SIZE=50
19
+ ALLOWED_EXTENSIONS=".jpg,.jpeg,.png,.webp"
20
+
21
+ # Detection Thresholds
22
+ REVIEW_THRESHOLD=0.65
23
+
24
+ # Metric Weights (must sum to 1.0)
25
+ GRADIENT_WEIGHT=0.30
26
+ FREQUENCY_WEIGHT=0.25
27
+ NOISE_WEIGHT=0.20
28
+ TEXTURE_WEIGHT=0.15
29
+ COLOR_WEIGHT=0.10
30
+
31
+ # Processing Configuration
32
+ ENABLE_CACHING=True
33
+ PROCESSING_TIMEOUT=30
34
+ PARALLEL_PROCESSING=False
35
+ MAX_WORKERS=1
36
+
37
+ # Paths (relative to project root)
38
+ BASE_DIR="."
39
+ UPLOAD_DIR="data/uploads"
40
+ REPORTS_DIR="data/reports"
41
+ CACHE_DIR="data/cache"
42
+ LOGS_DIR="logs"
43
+
44
+ # =========================================
45
+ # Hugging Face Spaces Specific
46
+ # =========================================
47
+ # These are automatically set by HF Spaces
48
+ # HF_SPACE_ID=""
49
+ # HF_SPACE_HOST=""
50
+
51
+ # =========================================
52
+ # Production Recommendations
53
+ # =========================================
54
+ # - Set DEBUG=False
55
+ # - Set LOG_LEVEL="WARNING" or "ERROR"
56
+ # - Adjust WORKERS based on available CPU cores
57
+ # - Enable PARALLEL_PROCESSING if CPU cores > 2
58
+ # - Set appropriate MAX_FILE_SIZE_MB for your use case
59
+ # =========================================
.gitignore ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ .env
7
+ .venv
8
+ venv/
9
+
10
+ # OS
11
+ .DS_Store
12
+
13
+ # Data / temp files
14
+ data/uploads/
15
+ data/cache/
16
+ data/reports/
17
+ logs/
18
+
19
+ # Models / large files
20
+ *.pt
21
+ *.bin
22
+ *.onnx
Dockerfile ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ===============================================================
2
+ # ImageScreenAI - Dockerfile : Optimized for Hugging Face Spaces
3
+ # ===============================================================
4
+
5
+ FROM python:3.11-slim
6
+
7
+ # Set working directory
8
+ WORKDIR /app
9
+
10
+ # Set environment variables
11
+ ENV PYTHONUNBUFFERED=1 \
12
+ PYTHONDONTWRITEBYTECODE=1 \
13
+ PIP_NO_CACHE_DIR=1 \
14
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
15
+ DEBIAN_FRONTEND=noninteractive
16
+
17
+ # Install system dependencies
18
+ RUN apt-get update && apt-get install -y --no-install-recommends \
19
+ # Required for OpenCV
20
+ libgl1-mesa-glx \
21
+ libglib2.0-0 \
22
+ libsm6 \
23
+ libxext6 \
24
+ libxrender-dev \
25
+ libgomp1 \
26
+ # Required for python-magic
27
+ libmagic1 \
28
+ # Build tools (removed after installation)
29
+ gcc \
30
+ g++ \
31
+ && rm -rf /var/lib/apt/lists/*
32
+
33
+ # Copy requirements first (layer caching optimization)
34
+ COPY requirements.txt .
35
+
36
+ # Install Python dependencies
37
+ RUN pip install --upgrade pip setuptools wheel && \
38
+ pip install -r requirements.txt && \
39
+ # Clean up to reduce image size
40
+ pip cache purge
41
+
42
+ # Copy application code
43
+ COPY . .
44
+
45
+ # Create necessary directories
46
+ RUN mkdir -p data/uploads data/reports data/cache logs && \
47
+ chmod -R 755 data logs
48
+
49
+ # Expose port (Hugging Face Spaces uses port 7860 by default)
50
+ EXPOSE 7860
51
+
52
+ # Health check
53
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
54
+ CMD python -c "import requests; requests.get('http://localhost:7860/health')" || exit 1
55
+
56
+ # Run the application
57
+ # Note: Hugging Face Spaces expects the app to listen on 0.0.0.0:7860
58
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md ADDED
@@ -0,0 +1,420 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ImageScreenAI
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ tags:
11
+ - ai-detection
12
+ - image-forensics
13
+ - computer-vision
14
+ - content-moderation
15
+ - screening-tool
16
+ ---
17
+
18
+ # ImageScreenAI: Statistical Screening Of Images For Authenticity Review
19
+
20
+ [![Python Version](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/)
21
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.104%2B-009688.svg)](https://fastapi.tiangolo.com/)
22
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
23
+ [![Code Style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
24
+
25
+ > **A transparent, unsupervised first-pass screening system for identifying images requiring human review in production workflows**
26
+
27
+ ---
28
+
29
+ ## 🎯 Overview
30
+
31
+ **ImageScreenAI** is not a "perfect AI detector." It is a **pragmatic screening tool** designed to reduce manual review workload by flagging potentially AI-generated images based on statistical and physical anomalies.
32
+
33
+ ### What This Is
34
+ ✅ A workflow efficiency tool
35
+ ✅ A transparent, explainable detector
36
+ ✅ A model-agnostic screening system
37
+ ✅ A first-pass filter, not a verdict engine
38
+
39
+ ### What This Is Not
40
+ ❌ A definitive "real vs fake" classifier
41
+ ❌ A black-box deep learning detector
42
+ ❌ A system claiming near-perfect accuracy on 2025 AI models
43
+
44
+ ---
45
+
46
+ ## 🚀 Key Features
47
+
48
+ - **Multi-Metric Ensemble**: 5 independent statistical detectors analyzing different AI generation failure modes
49
+ - **Binary UX**: Only two outcomes - `LIKELY_AUTHENTIC` or `REVIEW_REQUIRED` (no ambiguous "maybe")
50
+ - **Full Explainability**: Per-metric scores, confidence levels, and human-readable explanations
51
+ - **Batch Processing**: Parallel analysis of up to 50 images with progress tracking
52
+ - **Multiple Export Formats**: CSV, JSON, and PDF reports for integration into existing workflows
53
+ - **No External Dependencies**: No ML models, no cloud APIs - fully self-contained
54
+ - **Production Ready**: FastAPI backend, comprehensive error handling, configurable thresholds
55
+
56
+ ---
57
+
58
+ ## 📊 Detection Approach
59
+
60
+ ### The Core Philosophy
61
+
62
+ Instead of answering *"Is this image AI or real?"*, we answer:
63
+
64
+ > **"Does this image require human review?"**
65
+
66
+ This reframes the problem from classification to prioritization - far more valuable in real-world workflows.
67
+
68
+ ---
69
+
70
+ ## 🔬 Metrics Choice & Rationale
71
+
72
+ ### Why These Five Metrics?
73
+
74
+ Each metric targets a **different failure mode** of AI image generation models (diffusion models, GANs, etc.):
75
+
76
+ #### 1. **Gradient-Field PCA** (`metrics/gradient_field_pca.py`)
77
+ - **Weight**: 30%
78
+ - **Target**: Lighting inconsistencies in diffusion models
79
+ - **Rationale**: Real photos have gradients aligned with physical light sources. Diffusion models perform patch-based denoising, creating low-dimensional gradient structures inconsistent with physics.
80
+ - **Method**: Sobel gradients → PCA → eigenvalue ratio analysis
81
+ - **Threshold**: Eigenvalue ratio < 0.85 indicates suspicious structure
82
+ - **Research Basis**: [Gragnaniello et al. 2021](https://arxiv.org/abs/2104.02726) - "Perceptual Quality Assessment of Synthetic Images"
83
+
84
+ #### 2. **Frequency Analysis (FFT)** (`metrics/frequency_analyzer.py`)
85
+ - **Weight**: 25%
86
+ - **Target**: Unnatural spectral energy distributions
87
+ - **Rationale**: Camera optics and sensors produce characteristic frequency falloffs. AI models can create spectral peaks/gaps not found in nature.
88
+ - **Method**: 2D FFT → radial spectrum → high-frequency ratio + roughness + power-law deviation
89
+ - **Thresholds**: HF ratio outside [0.08, 0.35] indicates anomalies
90
+ - **Research Basis**: [Dzanic et al. 2020](https://arxiv.org/abs/2003.08685) - "Fourier Spectrum Discrepancies in Deep Network Generated Images"
91
+
92
+ #### 3. **Noise Pattern Analysis** (`metrics/noise_analyzer.py`)
93
+ - **Weight**: 20%
94
+ - **Target**: Missing or artificial sensor noise
95
+ - **Rationale**: Real cameras produce Poisson shot noise + Gaussian read noise with characteristic variance. AI models often produce overly uniform images or synthetic noise.
96
+ - **Method**: Patch-based Laplacian filtering → MAD estimation → CV + IQR analysis
97
+ - **Thresholds**: CV < 0.15 (too uniform) or > 1.2 (too variable) flags images
98
+ - **Research Basis**: [Kirchner & Johnson 2019](https://ieeexplore.ieee.org/document/8625351) - "SPN-CNN: Boosting Sensor Pattern Noise for Image Manipulation Detection"
99
+
100
+ #### 4. **Texture Statistics** (`metrics/texture_analyzer.py`)
101
+ - **Weight**: 15%
102
+ - **Target**: Overly smooth or repetitive regions
103
+ - **Rationale**: Natural scenes have organic texture variation. GANs can produce suspiciously smooth regions or repetitive patterns.
104
+ - **Method**: Patch-based entropy, contrast, edge density → distribution analysis
105
+ - **Thresholds**: >40% smooth patches (smoothness > 0.5) indicates anomalies
106
+ - **Research Basis**: [Nataraj et al. 2019](https://arxiv.org/abs/1912.11035) - "Detecting GAN Generated Fake Images using Co-occurrence Matrices"
107
+
108
+ #### 5. **Color Distribution** (`metrics/color_analyzer.py`)
109
+ - **Weight**: 10%
110
+ - **Target**: Impossible or highly unlikely color patterns
111
+ - **Rationale**: Physical light sources create constrained color relationships. AI can generate oversaturated or unnaturally clustered hues.
112
+ - **Method**: RGB→HSV conversion → saturation analysis + histogram roughness + hue concentration
113
+ - **Thresholds**: Mean saturation > 0.65 or top-3 hue bins > 60% flags images
114
+ - **Research Basis**: [Marra et al. 2019](https://arxiv.org/abs/1902.11153) - "Do GANs Leave Specific Traces?"
115
+
116
+ ---
117
+
118
+ ## ⚖️ Ensemble Approach
119
+
120
+ ### Weighted Aggregation Strategy
121
+
122
+ ```python
123
+ final_score = (
124
+ 0.30 × gradient_score +
125
+ 0.25 × frequency_score +
126
+ 0.20 × noise_score +
127
+ 0.15 × texture_score +
128
+ 0.10 × color_score
129
+ )
130
+ ```
131
+
132
+ ### Pros ✅
133
+
134
+ 1. **Robustness**: No single metric failure breaks the system
135
+ 2. **Diversity**: Each metric captures orthogonal information
136
+ 3. **Tunability**: Weights can be adjusted based on use case
137
+ 4. **Explainability**: Per-metric scores preserved for transparency
138
+ 5. **Fail-Safe**: Neutral scores (0.5) for metric failures prevent cascading errors
139
+
140
+ ### Cons ❌
141
+
142
+ 1. **Hyperparameter Sensitivity**: Weights are manually tuned, not learned
143
+ 2. **Assumption of Independence**: Metrics may correlate in practice (e.g., frequency ↔ noise)
144
+ 3. **Fixed Weights**: No adaptive weighting based on image characteristics
145
+ 4. **Threshold Brittleness**: Single threshold (0.65) for binary decision may not fit all contexts
146
+ 5. **No Adversarial Robustness**: Trivial post-processing can fool statistical detectors
147
+
148
+ ### Why Not Machine Learning?
149
+
150
+ - **Transparency**: Statistical methods are auditable; neural networks are black boxes
151
+ - **Generalization**: ML models overfit to training generators; unsupervised methods generalize better
152
+ - **Deployment**: No GPU required, no model versioning issues
153
+ - **Trust**: Users understand "gradient inconsistency" better than "neuron activation patterns"
154
+
155
+ ---
156
+
157
+ ## 🏗️ Architecture
158
+
159
+ ### High-Level Flow
160
+
161
+ ```
162
+ Image Upload → Validation → Parallel Metric Execution → Aggregation → Threshold Decision → Report Export
163
+ ```
164
+
165
+ ### Component Structure
166
+
167
+ ```
168
+ ImageScreenAI/
169
+ ├── app.py # FastAPI application entry point
170
+ ├── config/
171
+ │ ├── settings.py # Environment variables, weights, thresholds
172
+ │ ├── constants.py # Enums, metric parameters, explanations
173
+ │ └── schemas.py # Pydantic models for type safety
174
+ ├── metrics/
175
+ │ ├── gradient_field_pca.py # Gradient structure analysis
176
+ │ ├── frequency_analyzer.py # FFT-based spectral analysis
177
+ │ ├── noise_analyzer.py # Sensor noise pattern detection
178
+ │ ├── texture_analyzer.py # Statistical texture features
179
+ │ ├── color_analyzer.py # Color distribution anomalies
180
+ │ └── aggregator.py # Ensemble combination logic
181
+ ├── features/
182
+ │ ├── batch_processor.py # Parallel/sequential batch handling
183
+ │ ├── threshold_manager.py # Runtime threshold configuration
184
+ │ └── detailed_result_maker.py # Explainability extraction
185
+ ├── reporter/
186
+ │ ├── csv_reporter.py # CSV export for workflows
187
+ │ ├── json_reporter.py # JSON API responses
188
+ │ └── pdf_reporter.py # Professional reports
189
+ ├── utils/
190
+ │ ├── logger.py # Structured logging
191
+ │ ├── image_processor.py # Image loading, resizing, conversion
192
+ │ ├── validators.py # File validation
193
+ │ └── helpers.py # Utility functions
194
+ └── ui/
195
+ └── index.html # Single-page web interface
196
+ ```
197
+
198
+ **Detailed Architecture**: See [`docs/Architecture.md`](docs/Architecture.md)
199
+
200
+ ---
201
+
202
+ ## 📈 Performance Expectations
203
+
204
+ ### Detection Rates (Honest Estimates)
205
+
206
+ | Image Source | Expected Detection Rate |
207
+ |-------------|------------------------|
208
+ | Consumer AI tools (2022-2023) | 80–90% |
209
+ | Stable Diffusion 1.x / 2.x | 70–80% |
210
+ | Midjourney v5 / v6 | 55–70% |
211
+ | DALL·E 3 / Gemini Imagen 3 | 40–55% |
212
+ | Post-processed AI images | 30–45% |
213
+ | **False positives on real photos** | **~10–20%** |
214
+
215
+ ### Why These Rates?
216
+
217
+ 1. **Modern Models Are Good**: 2024-2025 generators produce physically plausible images
218
+ 2. **Post-Processing Erases Traces**: JPEG compression, filters, and resizing remove statistical artifacts
219
+ 3. **Real Photos Vary Widely**: Macro, long-exposure, and HDR photos trigger false positives
220
+ 4. **Adversarial Evasion**: Adding noise or slight edits defeats statistical detectors
221
+
222
+ ### Processing Performance
223
+
224
+ - **Single image**: 2–4 seconds
225
+ - **Batch (10 images)**: 15–25 seconds (parallel)
226
+ - **Memory**: 50–150 MB per image
227
+ - **Max concurrent workers**: 4 (configurable)
228
+
229
+ ---
230
+
231
+ ## 📦 Installation
232
+
233
+ ### Prerequisites
234
+
235
+ - Python 3.11+
236
+ - pip
237
+
238
+ ### Setup
239
+
240
+ ```bash
241
+ # Clone repository
242
+ git clone https://github.com/itobuztech/ImageScreenAI.git
243
+ cd ImageScreenAI
244
+
245
+ # Create virtual environment
246
+ python -m venv venv
247
+ source venv/bin/activate # On Windows: venv\Scripts\activate
248
+
249
+ # Install dependencies
250
+ pip install -r requirements.txt
251
+
252
+ # Create required directories
253
+ mkdir -p data/{uploads,reports,cache} logs
254
+
255
+ # Run server
256
+ python app.py
257
+ ```
258
+
259
+ Server will start at `http://localhost:8005`
260
+
261
+ ---
262
+
263
+ ## 🚀 Quick Start
264
+
265
+ ### Web Interface
266
+
267
+ 1. Open `http://localhost:8005` in browser
268
+ 2. Upload images (single or batch)
269
+ 3. View results with per-metric breakdowns
270
+ 4. Export reports (CSV/PDF)
271
+
272
+ ### API Usage
273
+
274
+ ```bash
275
+ # Single image analysis
276
+ curl -X POST http://localhost:8005/analyze/image \
277
+ -F "file=@example.jpg"
278
+
279
+ # Batch analysis
280
+ curl -X POST http://localhost:8005/analyze/batch \
281
+ -F "files=@img1.jpg" \
282
+ -F "files=@img2.png" \
283
+ -F "files=@img3.webp"
284
+
285
+ # Download CSV report
286
+ curl -X GET http://localhost:8005/report/csv/{batch_id} -o report.csv
287
+ ```
288
+
289
+ **Full API Documentation**: See [`docs/API.md`](docs/API.md)
290
+
291
+ ---
292
+
293
+ ## 📖 Documentation
294
+
295
+ | Document | Description |
296
+ |----------|-------------|
297
+ | [`docs/Architecture.md`](docs/Architecture.md) | System architecture, data flow diagrams, component details |
298
+ | [`docs/API.md`](docs/API.md) | Complete API reference with examples |
299
+
300
+ ---
301
+
302
+ ## 🔬 Scientific References
303
+
304
+ ### Core Detection Techniques
305
+
306
+ 1. **Gragnaniello, D., Cozzolino, D., Marra, F., Poggi, G., & Verdoliva, L.** (2021). "Are GAN Generated Images Easy to Detect? A Critical Analysis of the State-of-the-Art." *IEEE International Conference on Multimedia and Expo*. [Paper](https://arxiv.org/abs/2104.02726)
307
+
308
+ 2. **Dzanic, T., Shah, K., & Witherden, F.** (2020). "Fourier Spectrum Discrepancies in Deep Network Generated Images." *NeurIPS 2020*. [Paper](https://arxiv.org/abs/2003.08685)
309
+
310
+ 3. **Kirchner, M., & Johnson, M. K.** (2019). "SPN-CNN: Boosting Sensor Pattern Noise for Image Manipulation Detection." *IEEE International Workshop on Information Forensics and Security*. [Paper](https://ieeexplore.ieee.org/document/8625351)
311
+
312
+ 4. **Nataraj, L., Mohammed, T. M., Manjunath, B. S., Chandrasekaran, S., Flenner, A., Bappy, J. H., & Roy-Chowdhury, A. K.** (2019). "Detecting GAN Generated Fake Images using Co-occurrence Matrices." *Electronic Imaging*. [Paper](https://arxiv.org/abs/1912.11035)
313
+
314
+ 5. **Marra, F., Gragnaniello, D., Cozzolino, D., & Verdoliva, L.** (2019). "Detection of GAN-Generated Fake Images over Social Networks." *IEEE Conference on Multimedia Information Processing and Retrieval*. [Paper](https://arxiv.org/abs/1902.11153)
315
+
316
+ ### Diffusion Model Artifacts
317
+
318
+ 6. **Corvi, R., Cozzolino, D., Poggi, G., Nagano, K., & Verdoliva, L.** (2023). "Intriguing Properties of Synthetic Images: from Generative Adversarial Networks to Diffusion Models." *arXiv preprint*. [Paper](https://arxiv.org/abs/2304.06408)
319
+
320
+ 7. **Sha, Z., Li, Z., Yu, N., & Zhang, Y.** (2023). "DE-FAKE: Detection and Attribution of Fake Images Generated by Text-to-Image Diffusion Models." *ACM CCS 2023*. [Paper](https://arxiv.org/abs/2310.16617)
321
+
322
+ ### Ensemble Methods
323
+
324
+ 8. **Wang, S. Y., Wang, O., Zhang, R., Owens, A., & Efros, A. A.** (2020). "CNN-Generated Images Are Surprisingly Easy to Spot... for Now." *CVPR 2020*. [Paper](https://arxiv.org/abs/1912.11035)
325
+
326
+ ---
327
+
328
+ ## ⚠️ Ethical Considerations
329
+
330
+ ### Honest Positioning
331
+
332
+ This system:
333
+ - ✅ Never claims "real" or "fake" with certainty
334
+ - ✅ Provides probabilistic screening only
335
+ - ✅ Encourages human verification for all flagged images
336
+ - ✅ Documents methodology transparently
337
+ - ✅ Acknowledges false positive rates upfront
338
+
339
+ ### Appropriate Use Cases
340
+
341
+ **Suitable for:**
342
+ - Content moderation pre-screening (reduces human workload)
343
+ - Journalism workflows (identifies images needing verification)
344
+ - Stock photo platforms (flags for manual review)
345
+ - Legal discovery (prioritizes suspicious documents)
346
+
347
+ **Not suitable for:**
348
+ - Law enforcement as sole evidence
349
+ - Automated content rejection without human review
350
+ - High-stakes decisions (e.g., criminal prosecution)
351
+
352
+ ### Known Limitations
353
+
354
+ 1. **False Positives**: 10-20% of real photos flagged (especially HDR, macro, long-exposure)
355
+ 2. **Evolving Generators**: Detection rates decline as AI models improve
356
+ 3. **Post-Processing Evasion**: Simple filters can defeat statistical detectors
357
+ 4. **No Adversarial Robustness**: Not designed to resist intentional evasion
358
+
359
+ ---
360
+
361
+ ## 🛠️ Configuration
362
+
363
+ ### Environment Variables
364
+
365
+ Create `.env` file:
366
+
367
+ ```env
368
+ # Server
369
+ HOST=localhost
370
+ PORT=8005
371
+ WORKERS=4
372
+ DEBUG=False
373
+
374
+ # Detection
375
+ REVIEW_THRESHOLD=0.65
376
+
377
+ # Metric Weights (must sum to 1.0)
378
+ GRADIENT_WEIGHT=0.30
379
+ FREQUENCY_WEIGHT=0.25
380
+ NOISE_WEIGHT=0.20
381
+ TEXTURE_WEIGHT=0.15
382
+ COLOR_WEIGHT=0.10
383
+
384
+ # Processing
385
+ MAX_FILE_SIZE_MB=10
386
+ MAX_BATCH_SIZE=50
387
+ PROCESSING_TIMEOUT=30
388
+ PARALLEL_PROCESSING=True
389
+ MAX_WORKERS=4
390
+ ```
391
+
392
+ ### Sensitivity Modes
393
+
394
+ Adjust `REVIEW_THRESHOLD` in `config/settings.py`:
395
+
396
+ - **Conservative** (0.75): Fewer false positives, may miss some AI images
397
+ - **Balanced** (0.65): Recommended default
398
+ - **Aggressive** (0.55): Catch more AI images, more false positives
399
+
400
+ ---
401
+
402
+ ## 📄 License
403
+
404
+ This project is licensed under the MIT License - see [LICENSE](LICENSE) file for details.
405
+
406
+ ---
407
+
408
+ ## 🙏 Acknowledgments
409
+
410
+ - Research papers cited above for theoretical foundations
411
+ - FastAPI team for excellent web framework
412
+ - OpenCV and SciPy communities for image processing tools
413
+ - Users providing feedback on detection accuracy
414
+
415
+ ---
416
+
417
+ <p align="center">
418
+ <i>Built with transparency and honesty in mind.</i><br>
419
+ <i>Screening, not certainty. Efficiency, not perfection.</i>
420
+ </p>
app.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import uuid
3
+ import shutil
4
+ import signal
5
+ import uvicorn
6
+ import traceback
7
+ from typing import List
8
+ from typing import Dict
9
+ from pathlib import Path
10
+ from fastapi import File
11
+ from typing import Optional
12
+ from fastapi import Request
13
+ from fastapi import FastAPI
14
+ from fastapi import UploadFile
15
+ from fastapi import HTTPException
16
+ from utils.logger import get_logger
17
+ from config.settings import settings
18
+ from fastapi.responses import Response
19
+ from config.schemas import APIResponse
20
+ from config.schemas import AnalysisResult
21
+ from fastapi.responses import HTMLResponse
22
+ from fastapi.responses import JSONResponse
23
+ from utils.validators import ImageValidator
24
+ from fastapi.staticfiles import StaticFiles
25
+ from utils.helpers import generate_unique_id
26
+ from reporter.csv_reporter import CSVReporter
27
+ from reporter.pdf_reporter import PDFReporter
28
+ from config.schemas import BatchAnalysisResult
29
+ from reporter.json_reporter import JSONReporter
30
+ from utils.image_processor import ImageProcessor
31
+ from fastapi.middleware.cors import CORSMiddleware
32
+ from features.batch_processor import BatchProcessor
33
+ from features.threshold_manager import ThresholdManager
34
+
35
+
36
+ # Logging
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ # FastAPI App Definition
41
+ app = FastAPI(title = "ImageScreenAI",
42
+ version = settings.VERSION,
43
+ description = "First-pass AI image screening tool for bulk workflows",
44
+ )
45
+
46
+
47
+ # Serve static assets (if any later)
48
+ app.mount("/ui", StaticFiles(directory = "ui"), name = "ui")
49
+
50
+ # CORS (UI + API)
51
+ app.add_middleware(CORSMiddleware,
52
+ allow_origins = ["*"],
53
+ allow_credentials = True,
54
+ allow_methods = ["*"],
55
+ allow_headers = ["*"],
56
+ )
57
+
58
+ # Runtime State
59
+ SESSION_STORE: Dict[str, Dict] = {}
60
+
61
+ # Component Initialization
62
+ image_validator = ImageValidator()
63
+ image_processor = ImageProcessor()
64
+
65
+ threshold_manager = ThresholdManager()
66
+ threshold_manager = threshold_manager
67
+ batch_processor = BatchProcessor(threshold_manager = threshold_manager)
68
+
69
+ json_reporter = JSONReporter()
70
+ csv_reporter = CSVReporter()
71
+ pdf_reporter = PDFReporter()
72
+
73
+ UPLOAD_DIR = settings.UPLOAD_DIR
74
+ CACHE_DIR = settings.CACHE_DIR
75
+ REPORTS_DIR = settings.REPORTS_DIR
76
+
77
+ for d in [UPLOAD_DIR, CACHE_DIR, REPORTS_DIR]:
78
+ d.mkdir(parents = True,
79
+ exist_ok = True,
80
+ )
81
+
82
+
83
+ # Utility: Progress Callback
84
+ def _progress_callback(batch_id: str):
85
+ def callback(image_idx: int, total: int, filename: str):
86
+ session = SESSION_STORE.get(batch_id)
87
+ if (not session or (session.get("status") != "processing")):
88
+ return
89
+
90
+ session["progress"] = {"current" : image_idx,
91
+ "total" : total,
92
+ "filename" : filename,
93
+ }
94
+ return callback
95
+
96
+
97
+ # Utility: Housekeeping
98
+ def cleanup_temp_files():
99
+ try:
100
+ for folder in [UPLOAD_DIR, CACHE_DIR]:
101
+ for item in folder.iterdir():
102
+ if item.is_file():
103
+ item.unlink(missing_ok = True)
104
+
105
+ logger.info("Temporary files cleaned")
106
+
107
+ except Exception as e:
108
+ logger.warning(f"Cleanup failed: {e}")
109
+
110
+
111
+ def shutdown_handler(*_):
112
+ logger.warning("Shutdown signal received — cleaning up")
113
+ cleanup_temp_files()
114
+
115
+
116
+ signal.signal(signal.SIGINT, shutdown_handler)
117
+ signal.signal(signal.SIGTERM, shutdown_handler)
118
+
119
+
120
+ # Error Handling
121
+ @app.exception_handler(Exception)
122
+ async def global_exception_handler(request: Request, exc: Exception):
123
+ logger.error(f"Unhandled error: {exc}")
124
+ logger.debug(traceback.format_exc())
125
+
126
+ return JSONResponse(status_code = 500,
127
+ content = APIResponse(success = False,
128
+ message = "Internal server error",
129
+ ).model_dump()
130
+ )
131
+
132
+
133
+ # Home
134
+ @app.get("/", response_class = HTMLResponse)
135
+ def serve_frontend():
136
+ index_path = Path("ui/index.html")
137
+
138
+ if not index_path.exists():
139
+ raise HTTPException(status_code = 404,
140
+ detail = "UI not found",
141
+ )
142
+
143
+ return index_path.read_text(encoding = "utf-8")
144
+
145
+
146
+ # Health Check
147
+ @app.get("/health")
148
+ def health():
149
+ return {"status" : "ok",
150
+ "version" : settings.VERSION,
151
+ }
152
+
153
+
154
+ # Single Image Analysis
155
+ @app.post("/analyze/image")
156
+ async def analyze_single_image(file: UploadFile = File(...)):
157
+ image_id = generate_unique_id()
158
+ image_path = UPLOAD_DIR / f"{image_id}_{file.filename}"
159
+
160
+ image_validator.validate_image(file_path = image_path,
161
+ filename = file.filename,
162
+ file_size = file.size,
163
+ )
164
+
165
+ try:
166
+ with open(image_path, "wb") as f:
167
+ shutil.copyfileobj(file.file, f)
168
+
169
+ image = image_processor.load_image(image_path)
170
+
171
+ # image is a NumPy array → shape = (H, W, C) or (H, W)
172
+ height, width = image.shape[:2]
173
+
174
+ result: AnalysisResult = batch_processor.process_single(image = image_path,
175
+ filename = file.filename,
176
+ image_size = (width, height),
177
+ )
178
+
179
+ return APIResponse(success = True,
180
+ message = "Image analysis completed",
181
+ data = result.model_dump(),
182
+ )
183
+
184
+ finally:
185
+ image_path.unlink(missing_ok = True)
186
+
187
+
188
+ # Batch Image Analysis
189
+ @app.post("/analyze/batch")
190
+ async def analyze_batch(files: List[UploadFile] = File(...)):
191
+ if not files:
192
+ raise HTTPException(status_code = 400,
193
+ detail = "No files provided",
194
+ )
195
+
196
+ batch_id = str(uuid.uuid4())
197
+
198
+ SESSION_STORE[batch_id] = {"status" : "processing",
199
+ "progress" : {"current" : 0,
200
+ "total" : len(files),
201
+ },
202
+ }
203
+
204
+ image_entries = list()
205
+
206
+ try:
207
+ for file in files:
208
+ uid = generate_unique_id()
209
+ path = UPLOAD_DIR / f"{uid}_{file.filename}"
210
+
211
+ with open(path, "wb") as f:
212
+ shutil.copyfileobj(file.file, f)
213
+
214
+ image = image_processor.load_image(path)
215
+ height, width = image.shape[:2]
216
+
217
+ image_validator.validate_image(file_path = path,
218
+ filename = file.filename,
219
+ file_size = file.size,
220
+ )
221
+
222
+ image_entries.append({"path" : path,
223
+ "filename" : file.filename,
224
+ "size" : (width, height),
225
+ })
226
+
227
+ batch_result: BatchAnalysisResult = batch_processor.process_batch(image_files = image_entries,
228
+ on_progress = _progress_callback(batch_id),
229
+ )
230
+
231
+ SESSION_STORE[batch_id] = {"status" : "completed",
232
+ "progress" : SESSION_STORE[batch_id]["progress"],
233
+ "result" : batch_result,
234
+ }
235
+
236
+
237
+ return APIResponse(success = True,
238
+ message = "Batch analysis completed",
239
+ data = {"batch_id" : batch_id,
240
+ "result" : batch_result.model_dump(),
241
+ },
242
+ )
243
+
244
+ except KeyboardInterrupt:
245
+ SESSION_STORE[batch_id] = {"status" : "interrupted",
246
+ "progress" : SESSION_STORE[batch_id]["progress"],
247
+ }
248
+
249
+ raise HTTPException(status_code = 499,
250
+ detail = "Processing interrupted",
251
+ )
252
+
253
+ except Exception as e:
254
+ logger.error(f"Batch {batch_id} failed: {e}", exc_info = True)
255
+
256
+ SESSION_STORE[batch_id] = {"status" : "failed",
257
+ "error" : str(e),
258
+ }
259
+
260
+ raise HTTPException(status_code = 500,
261
+ detail = "Batch processing failed",
262
+ )
263
+
264
+ finally:
265
+ for item in image_entries:
266
+ Path(item["path"]).unlink(missing_ok = True)
267
+
268
+
269
+ # Batch Progress
270
+ @app.get("/batch/{batch_id}/progress")
271
+ def batch_progress(batch_id: str):
272
+ session = SESSION_STORE.get(batch_id)
273
+
274
+ if not session:
275
+ raise HTTPException(status_code = 404,
276
+ detail = "Batch not found",
277
+ )
278
+
279
+ return session
280
+
281
+
282
+ # Report Downloads
283
+ @app.api_route("/report/csv/{batch_id}", methods = ["GET", "POST"])
284
+ def export_csv(batch_id: str):
285
+ session = SESSION_STORE.get(batch_id)
286
+
287
+ if (not session or ("result" not in session)):
288
+ raise HTTPException(status_code = 404,
289
+ detail = "Batch result not found",
290
+ )
291
+
292
+ path = csv_reporter.export_batch_detailed(session["result"])
293
+
294
+ # Read the file and send it as a download
295
+ with open(path, "rb") as f:
296
+ content = f.read()
297
+
298
+ # Clean up the file after sending
299
+ path.unlink(missing_ok = True)
300
+
301
+ return Response(content = content,
302
+ media_type = "text/csv",
303
+ headers = {"Content-Disposition" : f"attachment; filename=ai_screener_report_{batch_id}.csv",
304
+ "Content-Type" : "text/csv"
305
+ }
306
+ )
307
+
308
+
309
+ @app.api_route("/report/pdf/{batch_id}", methods = ["GET", "POST"])
310
+ def export_pdf(batch_id: str):
311
+ session = SESSION_STORE.get(batch_id)
312
+
313
+ if (not session or ("result" not in session)):
314
+ raise HTTPException(status_code = 404,
315
+ detail = "Batch result not found",
316
+ )
317
+
318
+ path = pdf_reporter.export_batch(session["result"])
319
+
320
+ # Read the file and send it as a download
321
+ with open(path, "rb") as f:
322
+ content = f.read()
323
+
324
+ # Clean up the file after sending
325
+ path.unlink(missing_ok = True)
326
+
327
+ return Response(content = content,
328
+ media_type = "application/pdf",
329
+ headers = {"Content-Disposition" : f"attachment; filename=ai_screener_report_{batch_id}.pdf",
330
+ "Content-Type" : "application/pdf"
331
+ }
332
+ )
333
+
334
+
335
+
336
+ # ==================== MAIN ====================
337
+ if __name__ == "__main__":
338
+ # Explicit startup log (forces log file creation)
339
+ logger.info("Starting AI Image Screener API Server")
340
+
341
+ uvicorn.run("app:app",
342
+ host = settings.HOST,
343
+ port = settings.PORT,
344
+ reload = settings.DEBUG,
345
+ log_level = settings.LOG_LEVEL.lower(),
346
+ workers = 1 if settings.DEBUG else settings.WORKERS,
347
+ )
config/__init__.py ADDED
File without changes
config/constants.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ from enum import Enum
3
+ from dataclasses import dataclass
4
+
5
+
6
+ class DetectionStatus(str, Enum):
7
+ """
8
+ Overall detection status
9
+ """
10
+ LIKELY_AUTHENTIC = "LIKELY_AUTHENTIC"
11
+ REVIEW_REQUIRED = "REVIEW_REQUIRED"
12
+
13
+
14
+ class SignalStatus(str, Enum):
15
+ """
16
+ Individual signal status
17
+ """
18
+ PASSED = "passed"
19
+ WARNING = "warning"
20
+ FLAGGED = "flagged"
21
+
22
+
23
+ class FileFormat(str, Enum):
24
+ """
25
+ Supported file formats
26
+ """
27
+ JPG = ".jpg"
28
+ JPEG = ".jpeg"
29
+ PNG = ".png"
30
+ WEBP = ".webp"
31
+
32
+
33
+ class MetricType(str, Enum):
34
+ """
35
+ Detection metric types
36
+ """
37
+ GRADIENT = "gradient"
38
+ FREQUENCY = "frequency"
39
+ NOISE = "noise"
40
+ TEXTURE = "texture"
41
+ COLOR = "color"
42
+
43
+
44
+
45
+ # Signal thresholds
46
+ SIGNAL_THRESHOLDS = {SignalStatus.FLAGGED : 0.7,
47
+ SignalStatus.WARNING : 0.4,
48
+ SignalStatus.PASSED : 0.0,
49
+ }
50
+
51
+ # Metric explanations
52
+ METRIC_EXPLANATIONS = {MetricType.GRADIENT : {'high' : "Detected irregular gradient patterns typical of diffusion models. Natural photos show consistent lighting gradients shaped by physics.",
53
+ 'moderate' : "Some gradient inconsistencies detected. May indicate AI generation or heavy editing.",
54
+ 'normal' : "Gradient patterns are consistent with natural lighting and camera optics."
55
+ },
56
+ MetricType.FREQUENCY : {'high' : "Unusual frequency distribution detected. AI-generated images often show unnatural spectral patterns.",
57
+ 'moderate' : "Frequency patterns show some irregularities. Requires further review.",
58
+ 'normal' : "Frequency distribution matches expected patterns for authentic photographs."
59
+ },
60
+ MetricType.NOISE : {'high' : "Noise pattern is unnaturally uniform. Real camera sensors produce characteristic noise patterns.",
61
+ 'moderate' : "Noise distribution shows some anomalies. May indicate synthetic generation.",
62
+ 'normal' : "Noise characteristics are consistent with genuine camera sensor behavior."
63
+ },
64
+ MetricType.TEXTURE : {'high' : "Detected suspiciously smooth regions. Natural photos have organic texture variation.",
65
+ 'moderate' : "Some texture regions appear overly uniform. Further analysis recommended.",
66
+ 'normal' : "Texture variation is within expected ranges for authentic photographs."
67
+ },
68
+ MetricType.COLOR : {'high' : "Color distribution shows impossible or highly unlikely patterns.",
69
+ 'moderate' : "Some color histogram irregularities detected.",
70
+ 'normal' : "Color distribution is within normal ranges for real photographs."
71
+ }
72
+ }
73
+
74
+ # Basic Image Processing Constants
75
+ MIN_IMAGE_DIMENSION = 64
76
+ MAX_IMAGE_DIMENSION = 8192
77
+ LUMINANCE_WEIGHTS = (0.2126, 0.7152, 0.0722) # ITU-R BT.709
78
+ IMAGE_RESIZE_MAX_DIMENSION = 1024
79
+
80
+
81
+ # Gradient-Field PCA Detection Parameters
82
+ @dataclass(frozen = True)
83
+ class GradientFieldPCAParams:
84
+ """
85
+ Parameters for Gradient-Field PCA detection
86
+ """
87
+ # Random Seed For Reproducibility
88
+ RANDOM_SEED : int = 1234
89
+
90
+ # NEUTRAL_SCORE
91
+ NEUTRAL_SCORE : float = 0.5
92
+
93
+ # PCA Configuration
94
+ SAMPLE_SIZE : int = 10000 # Max gradient samples for PCA
95
+
96
+ # Thresholds
97
+ MAGNITUDE_THRESHOLD : float = 1e-6 # Minimum gradient magnitude to consider
98
+ MIN_SAMPLES : int = 10 # Minimum samples required for PCA
99
+ VARIANCE_THRESHOLD : float = 1e-10 # Minimum total variance
100
+ EIGENVALUE_RATIO_THRESHOLD : float = 0.85 # Real photos typically > 0.85
101
+
102
+
103
+
104
+ # Frequency Analysis Parameters
105
+ @dataclass(frozen = True)
106
+ class FrequencyAnalysisParams:
107
+ """
108
+ Parameters for FFT-based frequency analysis
109
+ """
110
+ # NEUTRAL_SCORE
111
+ NEUTRAL_SCORE : float = 0.5
112
+
113
+ # FFT Configuration
114
+ BINS : int = 64
115
+ HIGH_FREQ_THRESHOLD : float = 0.6 # Radial position where high-freq starts
116
+
117
+ # Analysis Thresholds
118
+ MIN_SPECTRUM_SAMPLES : int = 10
119
+ HF_RATIO_UPPER : float = 0.35 # High-frequency ratio upper bound
120
+ HF_RATIO_LOWER : float = 0.08 # High-frequency ratio lower bound
121
+
122
+ # Scaling Factors
123
+ HF_UPPER_SCALE : float = 3.0
124
+ HF_LOWER_SCALE : float = 5.0
125
+ ROUGHNESS_SCALE : float = 10.0
126
+ DEVIATION_SCALE : float = 2.0
127
+
128
+ # Sub-metric Weights
129
+ SUBMETRIC_WEIGHTS : dict = None
130
+
131
+ def __post_init__(self):
132
+ if self.SUBMETRIC_WEIGHTS is None:
133
+ object.__setattr__(self, 'SUBMETRIC_WEIGHTS', {'hf_anomaly' : 0.4,
134
+ 'roughness' : 0.3,
135
+ 'deviation' : 0.3,
136
+ }
137
+ )
138
+
139
+
140
+ # Noise Analysis Parameters
141
+ @dataclass(frozen = True)
142
+ class NoiseAnalysisParams:
143
+ """
144
+ Parameters for noise pattern analysis
145
+ """
146
+ # NEUTRAL SCORE
147
+ NEUTRAL_SCORE : float = 0.5
148
+
149
+ # Patch Configuration
150
+ PATCH_SIZE : int = 32
151
+ STRIDE : int = 16
152
+ SAMPLES : int = 100
153
+
154
+ # Variance Thresholds
155
+ VARIANCE_LOW_THRESHOLD : float = 1.0 # Skip too uniform patches
156
+ VARIANCE_HIGH_THRESHOLD : float = 1000.0 # Skip too structured patches
157
+
158
+ # MAD Conversion
159
+ MAD_TO_STD_FACTOR : float = 1.4826 # Gaussian: σ ≈ 1.4826 × MAD
160
+
161
+ # Distribution Analysis
162
+ MIN_ESTIMATES : int = 10
163
+ MIN_FILTERED_SAMPLES : int = 5
164
+ OUTLIER_PERCENTILE_LOW : int = 10
165
+ OUTLIER_PERCENTILE_HIGH : int = 90
166
+
167
+ # CV (Coefficient of Variation) Thresholds
168
+ CV_UNIFORM_THRESHOLD : float = 0.15
169
+ CV_VARIABLE_THRESHOLD : float = 1.2
170
+ CV_UNIFORM_SCALE : float = 5.0
171
+ CV_VARIABLE_SCALE : float = 2.0
172
+
173
+ # Noise Level Thresholds
174
+ LEVEL_CLEAN_THRESHOLD : float = 1.5
175
+ LEVEL_LOW_THRESHOLD : float = 2.5
176
+
177
+ # IQR Analysis
178
+ IQR_THRESHOLD : float = 0.3
179
+ IQR_SCALE : float = 2.0
180
+ IQR_PERCENTILE_LOW : int = 25
181
+ IQR_PERCENTILE_HIGH : int = 75
182
+
183
+ # Sub-metric Weights
184
+ SUBMETRIC_WEIGHTS : dict = None
185
+
186
+ def __post_init__(self):
187
+ if self.SUBMETRIC_WEIGHTS is None:
188
+ object.__setattr__(self, 'SUBMETRIC_WEIGHTS', {'cv_anomaly' : 0.4,
189
+ 'noise_level_anomaly' : 0.4,
190
+ 'iqr_anomaly' : 0.2,
191
+ }
192
+ )
193
+
194
+
195
+ # Texture Analysis Parameters
196
+ @dataclass(frozen = True)
197
+ class TextureAnalysisParams:
198
+ """
199
+ Parameters for texture analysis
200
+ """
201
+ # Random Seed for reproducibility
202
+ RANDOM_SEED : int = 1234
203
+
204
+ # Neutral Score
205
+ NEUTRAL_SCORE : float = 0.5
206
+
207
+ # Patch Configuration
208
+ PATCH_SIZE : int = 64
209
+ N_PATCHES : int = 50
210
+
211
+ # Histogram Configuration
212
+ HISTOGRAM_BINS : int = 32
213
+ HISTOGRAM_RANGE : tuple = (0, 255)
214
+
215
+ # Edge Detection
216
+ EDGE_THRESHOLD : float = 10.0
217
+
218
+ # Smoothness Analysis
219
+ SMOOTHNESS_THRESHOLD : float = 0.5
220
+ SMOOTH_RATIO_THRESHOLD : float = 0.4
221
+ SMOOTH_RATIO_SCALE : float = 2.5
222
+
223
+ # Entropy Analysis
224
+ ENTROPY_CV_THRESHOLD : float = 0.15
225
+ ENTROPY_SCALE : float = 5.0
226
+
227
+ # Contrast Analysis
228
+ CONTRAST_CV_LOW : float = 0.3
229
+ CONTRAST_CV_HIGH : float = 1.5
230
+ CONTRAST_LOW_SCALE : float = 2.0
231
+ CONTRAST_HIGH_SCALE : float = 0.5
232
+
233
+ # Edge Density Analysis
234
+ EDGE_CV_THRESHOLD : float = 0.4
235
+ EDGE_SCALE : float = 1.5
236
+
237
+ # Sub-metric Weights
238
+ SUBMETRIC_WEIGHTS : dict = None
239
+
240
+ def __post_init__(self):
241
+ if self.SUBMETRIC_WEIGHTS is None:
242
+ object.__setattr__(self, 'SUBMETRIC_WEIGHTS', {'smoothness_anomaly' : 0.35,
243
+ 'entropy_anomaly' : 0.25,
244
+ 'contrast_anomaly' : 0.25,
245
+ 'edge_anomaly' : 0.15,
246
+ }
247
+ )
248
+
249
+
250
+ # Color Analysis Parameters
251
+ @dataclass(frozen = True)
252
+ class ColorAnalysisParams:
253
+ """
254
+ Parameters for color distribution analysis
255
+ """
256
+ # Random Seed for reproducibility
257
+ RANDOM_SEED : int = 1234
258
+
259
+ # Neutral Score
260
+ NEUTRAL_SCORE : float = 0.5
261
+ # Saturation Analysis
262
+ SAT_HIGH_THRESHOLD : float = 0.8
263
+ SAT_VERY_HIGH_THRESHOLD : float = 0.95
264
+ SAT_MEAN_THRESHOLD : float = 0.65
265
+ SAT_MEAN_SCALE : float = 3.0
266
+ HIGH_SAT_RATIO_THRESHOLD : float = 0.20
267
+ HIGH_SAT_SCALE : float = 2.5
268
+ CLIP_RATIO_THRESHOLD : float = 0.05
269
+ CLIP_SCALE : float = 10.0
270
+
271
+ # Histogram Analysis
272
+ HISTOGRAM_BINS : int = 64
273
+ HISTOGRAM_RANGE : tuple = (0, 1)
274
+ ROUGHNESS_THRESHOLD : float = 0.015
275
+ ROUGHNESS_SCALE : float = 50.0
276
+ CLIP_THRESHOLD : float = 0.10
277
+ CLIP_SCALE_FACTOR : float = 5.0
278
+
279
+ # Hue Analysis
280
+ HUE_SAT_MASK_THRESHOLD : float = 0.2
281
+ HUE_MIN_PIXELS : int = 100
282
+ HUE_BINS : int = 36
283
+ HUE_RANGE : tuple = (0, 360)
284
+ HUE_CONCENTRATION_THRESHOLD : float = 0.6
285
+ HUE_CONCENTRATION_SCALE : float = 2.5
286
+ HUE_EMPTY_BIN_THRESHOLD : float = 0.01
287
+ HUE_GAP_RATIO_THRESHOLD : float = 0.4
288
+ HUE_GAP_SCALE : float = 1.5
289
+
290
+ # Sub-metric Weights
291
+ SAT_SUBMETRIC_WEIGHTS : dict = None
292
+ HUE_SUBMETRIC_WEIGHTS : dict = None
293
+ MAIN_WEIGHTS : dict = None
294
+
295
+ def __post_init__(self):
296
+ if self.SAT_SUBMETRIC_WEIGHTS is None:
297
+ object.__setattr__(self, 'SAT_SUBMETRIC_WEIGHTS', {'mean_anomaly' : 0.3,
298
+ 'high_sat_anomaly' : 0.4,
299
+ 'clip_anomaly' : 0.3,
300
+ }
301
+ )
302
+
303
+ if self.HUE_SUBMETRIC_WEIGHTS is None:
304
+ object.__setattr__(self, 'HUE_SUBMETRIC_WEIGHTS', {'concentration_anomaly' : 0.6,
305
+ 'gap_anomaly' : 0.4,
306
+ }
307
+ )
308
+
309
+ if self.MAIN_WEIGHTS is None:
310
+ object.__setattr__(self, 'MAIN_WEIGHTS', {'saturation' : 0.4,
311
+ 'histogram' : 0.35,
312
+ 'hue' : 0.25,
313
+ }
314
+ )
315
+
316
+
317
+
318
+ # Singleton instances for parameter classes
319
+ GRADIENT_FIELD_PCA_PARAMS = GradientFieldPCAParams()
320
+ FREQUENCY_ANALYSIS_PARAMS = FrequencyAnalysisParams()
321
+ NOISE_ANALYSIS_PARAMS = NoiseAnalysisParams()
322
+ TEXTURE_ANALYSIS_PARAMS = TextureAnalysisParams()
323
+ COLOR_ANALYSIS_PARAMS = ColorAnalysisParams()
324
+
325
+
config/schemas.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ from typing import List
3
+ from typing import Dict
4
+ from pydantic import Field
5
+ from typing import Optional
6
+ from datetime import datetime
7
+ from pydantic import BaseModel
8
+ from config.constants import MetricType
9
+ from config.constants import SignalStatus
10
+ from config.constants import DetectionStatus
11
+
12
+
13
+ class MetricResult(BaseModel):
14
+ """
15
+ Raw metric output for explainability and reporting
16
+ """
17
+ metric_type : MetricType
18
+ score : float = Field(..., ge = 0.0, le = 1.0)
19
+ confidence : Optional[float] = Field(None, ge = 0.0, le = 1.0)
20
+ details : Optional[Dict] = Field(default_factory = dict)
21
+
22
+ model_config = {"json_schema_extra" : {"example" : {"metric_type" : "noise",
23
+ "score" : 0.72,
24
+ "confidence" : 0.81,
25
+ "details" : {"patches_total" : 100,
26
+ "patches_valid" : 42,
27
+ "mean_noise" : 1.12,
28
+ "cv" : 0.18
29
+ }
30
+ }
31
+ }
32
+ }
33
+
34
+
35
+ class DetectionSignal(BaseModel):
36
+ """
37
+ Individual detection signal result
38
+ """
39
+ name : str = Field(..., description = "Metric name")
40
+ metric_type : MetricType
41
+ score : float = Field(..., ge = 0.0, le = 1.0, description = "Suspicion score (0=natural, 1=suspicious)")
42
+ status : SignalStatus
43
+ explanation : str = Field(..., description = "Human-readable explanation")
44
+
45
+ model_config = {"json_schema_extra" : {"example" : {"name" : "Gradient Pattern",
46
+ "metric_type" : "gradient",
47
+ "score" : 0.73,
48
+ "status" : "flagged",
49
+ "explanation" : "Detected irregular gradient patterns typical of diffusion models."
50
+ }
51
+ }
52
+ }
53
+
54
+
55
+ class AnalysisResult(BaseModel):
56
+ """
57
+ Single image analysis result
58
+ """
59
+ filename : str
60
+ overall_score : float = Field(..., ge = 0.0, le = 1.0)
61
+ status : DetectionStatus
62
+ confidence : int = Field(..., ge = 0, le = 100, description = "Confidence percentage")
63
+ signals : List[DetectionSignal]
64
+ metric_results : Dict[MetricType, MetricResult]
65
+ processing_time : float = Field(..., description = "Processing time in seconds")
66
+ timestamp : datetime = Field(default_factory = datetime.now)
67
+ image_size : tuple[int, int] = Field(..., description = "Width x Height")
68
+
69
+ model_config = {"json_schema_extra" : {"example" : {"filename" : "photo_001.jpg",
70
+ "overall_score" : 0.73,
71
+ "status" : "REVIEW_REQUIRED",
72
+ "confidence" : 73,
73
+ "signals" : [],
74
+ "processing_time" : 2.34,
75
+ "image_size" : [1920, 1080]
76
+ }
77
+ }
78
+ }
79
+
80
+
81
+ class BatchAnalysisResult(BaseModel):
82
+ """
83
+ Batch analysis result
84
+ """
85
+ total_images : int
86
+ processed : int
87
+ failed : int
88
+ results : List[AnalysisResult]
89
+ summary : Dict[str, float] = Field(default_factory = dict, description = "Summary statistics")
90
+ total_processing_time : float
91
+ timestamp : datetime = Field(default_factory = datetime.now)
92
+
93
+
94
+ class APIResponse(BaseModel):
95
+ """
96
+ Standard API response wrapper
97
+ """
98
+ success : bool
99
+ message : str
100
+ data : Optional[Dict] = None
101
+ error : Optional[str] = None
102
+ timestamp : datetime = Field(default_factory = datetime.now)
103
+
104
+
105
+ class HealthResponse(BaseModel):
106
+ """
107
+ Health check response
108
+ """
109
+ status : str
110
+ version : str
111
+ uptime : float
112
+ timestamp : datetime = Field(default_factory = datetime.now)
config/settings.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ from typing import Set
3
+ from pathlib import Path
4
+ from config.constants import MetricType
5
+ from pydantic_settings import BaseSettings
6
+ from pydantic_settings import SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ """
11
+ Application settings with environment variable support
12
+ """
13
+ model_config = SettingsConfigDict(env_file = '.env',
14
+ env_file_encoding = 'utf-8',
15
+ case_sensitive = False,
16
+ )
17
+
18
+ # Application
19
+ APP_NAME : str = "ImageScreenAI"
20
+ VERSION : str = "1.0.0"
21
+ DEBUG : bool = False
22
+ LOG_LEVEL : str = "INFO"
23
+
24
+ # Server Configuration
25
+ HOST : str = "localhost"
26
+ PORT : int = 8005
27
+ WORKERS : int = 4
28
+
29
+ # File processing
30
+ MAX_FILE_SIZE_MB : int = 10
31
+ MAX_BATCH_SIZE : int = 50
32
+ ALLOWED_EXTENSIONS : Set[str] = {".jpg", ".jpeg", ".png", ".webp"}
33
+
34
+ # Detection thresholds
35
+ REVIEW_THRESHOLD : float = 0.65
36
+
37
+ # Metric weights (must sum to 1.0)
38
+ GRADIENT_WEIGHT : float = 0.30
39
+ FREQUENCY_WEIGHT : float = 0.25
40
+ NOISE_WEIGHT : float = 0.20
41
+ TEXTURE_WEIGHT : float = 0.15
42
+ COLOR_WEIGHT : float = 0.10
43
+
44
+ # Processing
45
+ ENABLE_CACHING : bool = True
46
+ PROCESSING_TIMEOUT : int = 30
47
+ PARALLEL_PROCESSING : bool = True
48
+ MAX_WORKERS : int = 4
49
+
50
+ # Paths
51
+ BASE_DIR : Path = Path(__file__).parent.parent
52
+ UPLOAD_DIR : Path = BASE_DIR / "data" / "uploads"
53
+ REPORTS_DIR : Path = BASE_DIR / "data" / "reports"
54
+ CACHE_DIR : Path = BASE_DIR / "data" / "cache"
55
+ LOGS_DIR : Path = BASE_DIR / "logs"
56
+
57
+
58
+
59
+ def __init__(self, **kwargs):
60
+ super().__init__(**kwargs)
61
+ self._create_directories()
62
+ self._validate_weights()
63
+
64
+
65
+ def _create_directories(self):
66
+ """
67
+ Ensure all required directories exist
68
+ """
69
+ for directory in [self.UPLOAD_DIR, self.REPORTS_DIR, self.CACHE_DIR, self.LOGS_DIR]:
70
+ directory.mkdir(parents = True,
71
+ exist_ok = True,
72
+ )
73
+
74
+ def _validate_weights(self):
75
+ """
76
+ Validate metric weights sum to 1.0
77
+ """
78
+ total = (self.GRADIENT_WEIGHT +
79
+ self.FREQUENCY_WEIGHT +
80
+ self.NOISE_WEIGHT +
81
+ self.TEXTURE_WEIGHT +
82
+ self.COLOR_WEIGHT
83
+ )
84
+
85
+ if (not (0.99 <= total <= 1.01)):
86
+ raise ValueError(f"Metric weights must sum to 1.0, got {total}")
87
+
88
+
89
+ @property
90
+ def max_file_size_bytes(self) -> int:
91
+ return self.MAX_FILE_SIZE_MB * 1024 * 1024
92
+
93
+
94
+ def get_metric_weights(self) -> dict:
95
+ """
96
+ Get all metric weights as dictionary
97
+ """
98
+ return {MetricType.GRADIENT : self.GRADIENT_WEIGHT,
99
+ MetricType.FREQUENCY : self.FREQUENCY_WEIGHT,
100
+ MetricType.NOISE : self.NOISE_WEIGHT,
101
+ MetricType.TEXTURE : self.TEXTURE_WEIGHT,
102
+ MetricType.COLOR : self.COLOR_WEIGHT
103
+ }
104
+
105
+
106
+ # Singleton
107
+ settings = Settings()
docs/API_DOCUMENTATION.md ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API Documentation
2
+
3
+ ## Base Information
4
+
5
+ **Base URL**: `http://localhost:8005`
6
+ **API Version**: `1.0.0`
7
+ **Protocol**: HTTP/HTTPS
8
+ **Content Type**: `application/json` (default)
9
+
10
+ ---
11
+
12
+ ## Table of Contents
13
+
14
+ 1. [Authentication](#authentication)
15
+ 2. [Health Check](#health-check)
16
+ 3. [Single Image Analysis](#single-image-analysis)
17
+ 4. [Batch Image Analysis](#batch-image-analysis)
18
+ 5. [Batch Progress Tracking](#batch-progress-tracking)
19
+ 6. [Report Export](#report-export)
20
+ 7. [Error Handling](#error-handling)
21
+ 8. [Rate Limits](#rate-limits)
22
+ 9. [Data Models](#data-models)
23
+
24
+ ---
25
+
26
+ ## Authentication
27
+
28
+ **Current Version**: No authentication required (intended for internal deployment)
29
+
30
+ **Future Versions**: API key authentication planned
31
+ ```bash
32
+ # Planned header format
33
+ Authorization: Bearer <api_key>
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Health Check
39
+
40
+ ### `GET /health`
41
+
42
+ Check if the API server is operational.
43
+
44
+ **Request**
45
+ ```bash
46
+ curl -X GET http://localhost:8005/health
47
+ ```
48
+
49
+ **Response** (`200 OK`)
50
+ ```json
51
+ {
52
+ "status": "ok",
53
+ "version": "1.0.0"
54
+ }
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Single Image Analysis
60
+
61
+ ### `POST /analyze/image`
62
+
63
+ Analyze a single image for AI-generation indicators.
64
+
65
+ **Request**
66
+
67
+ ```bash
68
+ curl -X POST http://localhost:8005/analyze/image \
69
+ -F "file=@/path/to/image.jpg"
70
+ ```
71
+
72
+ **Parameters**
73
+
74
+ | Name | Type | Required | Description |
75
+ |------|------|----------|-------------|
76
+ | `file` | File | Yes | Image file (JPG/PNG/WEBP, max 10MB) |
77
+
78
+ **Response** (`200 OK`)
79
+
80
+ ```json
81
+ {
82
+ "success": true,
83
+ "message": "Image analysis completed",
84
+ "data": {
85
+ "filename": "example.jpg",
86
+ "status": "REVIEW_REQUIRED",
87
+ "overall_score": 0.73,
88
+ "confidence": 73,
89
+ "signals": [
90
+ {
91
+ "name": "Gradient Field PCA",
92
+ "metric_type": "gradient",
93
+ "score": 0.81,
94
+ "status": "flagged",
95
+ "explanation": "Detected irregular gradient patterns typical of diffusion models. Natural photos show consistent lighting gradients shaped by physics."
96
+ },
97
+ {
98
+ "name": "Frequency Analysis",
99
+ "metric_type": "frequency",
100
+ "score": 0.68,
101
+ "status": "warning",
102
+ "explanation": "Frequency patterns show some irregularities. Requires further review."
103
+ },
104
+ {
105
+ "name": "Noise Analysis",
106
+ "metric_type": "noise",
107
+ "score": 0.72,
108
+ "status": "flagged",
109
+ "explanation": "Noise pattern is unnaturally uniform. Real camera sensors produce characteristic noise patterns."
110
+ },
111
+ {
112
+ "name": "Texture Analysis",
113
+ "metric_type": "texture",
114
+ "score": 0.65,
115
+ "status": "warning",
116
+ "explanation": "Some texture regions appear overly uniform. Further analysis recommended."
117
+ },
118
+ {
119
+ "name": "Color Analysis",
120
+ "metric_type": "color",
121
+ "score": 0.54,
122
+ "status": "warning",
123
+ "explanation": "Some color histogram irregularities detected."
124
+ }
125
+ ],
126
+ "metric_results": {
127
+ "gradient": {
128
+ "metric_type": "gradient",
129
+ "score": 0.81,
130
+ "confidence": 0.87,
131
+ "details": {
132
+ "eigenvalue_ratio": 0.72,
133
+ "gradient_vectors_sampled": 10000,
134
+ "threshold": 0.85
135
+ }
136
+ },
137
+ "frequency": {
138
+ "metric_type": "frequency",
139
+ "score": 0.68,
140
+ "confidence": 0.65,
141
+ "details": {
142
+ "hf_ratio": 0.38,
143
+ "hf_anomaly": 0.45,
144
+ "roughness": 0.032,
145
+ "spectral_deviation": 0.21
146
+ }
147
+ },
148
+ "noise": {
149
+ "metric_type": "noise",
150
+ "score": 0.72,
151
+ "confidence": 0.78,
152
+ "details": {
153
+ "mean_noise": 1.12,
154
+ "cv": 0.18,
155
+ "patches_valid": 42,
156
+ "patches_total": 100
157
+ }
158
+ },
159
+ "texture": {
160
+ "metric_type": "texture",
161
+ "score": 0.65,
162
+ "confidence": 0.71,
163
+ "details": {
164
+ "smooth_ratio": 0.45,
165
+ "contrast_mean": 18.3,
166
+ "entropy_mean": 4.2,
167
+ "patches_used": 50
168
+ }
169
+ },
170
+ "color": {
171
+ "metric_type": "color",
172
+ "score": 0.54,
173
+ "confidence": 0.58,
174
+ "details": {
175
+ "saturation_stats": {
176
+ "mean_saturation": 0.68,
177
+ "high_sat_ratio": 0.23,
178
+ "very_high_sat_ratio": 0.06
179
+ },
180
+ "histogram_stats": {
181
+ "roughness_mean": 0.021,
182
+ "channels_analyzed": 3
183
+ },
184
+ "hue_stats": {
185
+ "top3_concentration": 0.58,
186
+ "gap_ratio": 0.32
187
+ }
188
+ }
189
+ }
190
+ },
191
+ "processing_time": 2.34,
192
+ "image_size": [1920, 1080],
193
+ "timestamp": "2024-12-19T14:32:15.123456"
194
+ },
195
+ "timestamp": "2024-12-19T14:32:15.123456"
196
+ }
197
+ ```
198
+
199
+ **Status Values**
200
+ - `LIKELY_AUTHENTIC`: Score < 0.65 (default threshold)
201
+ - `REVIEW_REQUIRED`: Score >= 0.65
202
+
203
+ **Signal Status Values**
204
+ - `passed`: Score < 0.40
205
+ - `warning`: Score >= 0.40 and < 0.70
206
+ - `flagged`: Score >= 0.70
207
+
208
+ ---
209
+
210
+ ## Batch Image Analysis
211
+
212
+ ### `POST /analyze/batch`
213
+
214
+ Analyze multiple images in a single request with parallel processing.
215
+
216
+ **Request**
217
+
218
+ ```bash
219
+ curl -X POST http://localhost:8005/analyze/batch \
220
+ -F "files=@image1.jpg" \
221
+ -F "files=@image2.png" \
222
+ -F "files=@image3.webp"
223
+ ```
224
+
225
+ **Parameters**
226
+
227
+ | Name | Type | Required | Description |
228
+ |------|------|----------|-------------|
229
+ | `files` | File[] | Yes | Multiple image files (max 50 per batch) |
230
+
231
+ **Response** (`200 OK`)
232
+
233
+ ```json
234
+ {
235
+ "success": true,
236
+ "message": "Batch analysis completed",
237
+ "data": {
238
+ "batch_id": "550e8400-e29b-41d4-a716-446655440000",
239
+ "result": {
240
+ "total_images": 3,
241
+ "processed": 3,
242
+ "failed": 0,
243
+ "results": [
244
+ {
245
+ "filename": "image1.jpg",
246
+ "status": "REVIEW_REQUIRED",
247
+ "overall_score": 0.73,
248
+ "confidence": 73,
249
+ "signals": [...],
250
+ "metric_results": {...},
251
+ "processing_time": 2.1,
252
+ "image_size": [1920, 1080],
253
+ "timestamp": "2024-12-19T14:32:15.123456"
254
+ },
255
+ {
256
+ "filename": "image2.png",
257
+ "status": "LIKELY_AUTHENTIC",
258
+ "overall_score": 0.42,
259
+ "confidence": 42,
260
+ "signals": [...],
261
+ "metric_results": {...},
262
+ "processing_time": 2.3,
263
+ "image_size": [2048, 1536],
264
+ "timestamp": "2024-12-19T14:32:17.234567"
265
+ },
266
+ {
267
+ "filename": "image3.webp",
268
+ "status": "LIKELY_AUTHENTIC",
269
+ "overall_score": 0.38,
270
+ "confidence": 38,
271
+ "signals": [...],
272
+ "metric_results": {...},
273
+ "processing_time": 1.9,
274
+ "image_size": [1024, 768],
275
+ "timestamp": "2024-12-19T14:32:19.345678"
276
+ }
277
+ ],
278
+ "summary": {
279
+ "likely_authentic": 2,
280
+ "review_required": 1,
281
+ "success_rate": 100,
282
+ "processed": 3,
283
+ "failed": 0,
284
+ "avg_score": 0.510,
285
+ "avg_confidence": 51,
286
+ "avg_proc_time": 2.10
287
+ },
288
+ "total_processing_time": 6.3,
289
+ "timestamp": "2024-12-19T14:32:19.345678"
290
+ }
291
+ },
292
+ "timestamp": "2024-12-19T14:32:19.345678"
293
+ }
294
+ ```
295
+
296
+ **Batch Constraints**
297
+ - Maximum images per batch: **50**
298
+ - Maximum file size per image: **10 MB**
299
+ - Timeout per image: **30 seconds**
300
+ - Total batch timeout: **15 minutes**
301
+
302
+ ---
303
+
304
+ ## Batch Progress Tracking
305
+
306
+ ### `GET /batch/{batch_id}/progress`
307
+
308
+ Track the progress of a batch analysis job.
309
+
310
+ **Request**
311
+
312
+ ```bash
313
+ curl -X GET http://localhost:8005/batch/550e8400-e29b-41d4-a716-446655440000/progress
314
+ ```
315
+
316
+ **Response - Processing** (`200 OK`)
317
+
318
+ ```json
319
+ {
320
+ "status": "processing",
321
+ "progress": {
322
+ "current": 7,
323
+ "total": 10,
324
+ "filename": "image_007.jpg"
325
+ }
326
+ }
327
+ ```
328
+
329
+ **Response - Completed** (`200 OK`)
330
+
331
+ ```json
332
+ {
333
+ "status": "completed",
334
+ "progress": {
335
+ "current": 10,
336
+ "total": 10,
337
+ "filename": "image_010.jpg"
338
+ },
339
+ "result": {
340
+ "total_images": 10,
341
+ "processed": 10,
342
+ "failed": 0,
343
+ "results": [...],
344
+ "summary": {...},
345
+ "total_processing_time": 21.4,
346
+ "timestamp": "2024-12-19T14:35:22.123456"
347
+ }
348
+ }
349
+ ```
350
+
351
+ **Response - Failed** (`200 OK`)
352
+
353
+ ```json
354
+ {
355
+ "status": "failed",
356
+ "error": "Processing timeout exceeded"
357
+ }
358
+ ```
359
+
360
+ **Status Values**
361
+ - `processing`: Batch is currently being analyzed
362
+ - `completed`: All images processed successfully
363
+ - `failed`: Batch processing encountered fatal error
364
+ - `interrupted`: Processing was manually stopped
365
+
366
+ ---
367
+
368
+ ## Report Export
369
+
370
+ ### CSV Export
371
+
372
+ #### `GET /report/csv/{batch_id}` or `POST /report/csv/{batch_id}`
373
+
374
+ Download detailed batch analysis as CSV file.
375
+
376
+ **Request**
377
+
378
+ ```bash
379
+ curl -X GET http://localhost:8005/report/csv/550e8400-e29b-41d4-a716-446655440000 \
380
+ -o report.csv
381
+ ```
382
+
383
+ **Response**
384
+
385
+ - Content-Type: `text/csv`
386
+ - File download with comprehensive analysis data
387
+ - Includes: per-image results, metric breakdowns, forensic details
388
+
389
+ **CSV Structure**
390
+ ```
391
+ BATCH STATISTICS
392
+ Total Images,10
393
+ Successfully Processed,10
394
+ Failed,0
395
+ ...
396
+
397
+ ANALYSIS RESULTS
398
+ Filename,Status,Overall Score,Confidence,Processing Time
399
+ image1.jpg,REVIEW_REQUIRED,0.73,73,2.1
400
+ image2.png,LIKELY_AUTHENTIC,0.42,42,2.3
401
+ ...
402
+
403
+ IMAGE 1 DETAILED ANALYSIS
404
+ Metric Name,Score,Status,Explanation
405
+ Gradient Field PCA,0.81,flagged,Detected irregular gradient patterns...
406
+ ...
407
+ ```
408
+
409
+ ---
410
+
411
+ ### PDF Export
412
+
413
+ #### `GET /report/pdf/{batch_id}` or `POST /report/pdf/{batch_id}`
414
+
415
+ Download detailed batch analysis as PDF report.
416
+
417
+ **Request**
418
+
419
+ ```bash
420
+ curl -X GET http://localhost:8005/report/pdf/550e8400-e29b-41d4-a716-446655440000 \
421
+ -o report.pdf
422
+ ```
423
+
424
+ **Response**
425
+
426
+ - Content-Type: `application/pdf`
427
+ - Professional formatted report with:
428
+ - Executive summary
429
+ - Per-image analysis sections
430
+ - Visual metric breakdowns
431
+ - Forensic details
432
+ - Recommendations
433
+
434
+ ---
435
+
436
+ ## Error Handling
437
+
438
+ ### Error Response Format
439
+
440
+ All errors return a standardized JSON structure:
441
+
442
+ ```json
443
+ {
444
+ "success": false,
445
+ "message": "Error description",
446
+ "error": "Detailed error message",
447
+ "timestamp": "2024-12-19T14:32:15.123456"
448
+ }
449
+ ```
450
+
451
+ ### HTTP Status Codes
452
+
453
+ | Code | Meaning | Description |
454
+ |------|---------|-------------|
455
+ | `200` | OK | Request successful |
456
+ | `400` | Bad Request | Invalid input (file format, size, etc.) |
457
+ | `404` | Not Found | Batch ID not found |
458
+ | `413` | Payload Too Large | File size exceeds 10MB |
459
+ | `422` | Unprocessable Entity | Validation error |
460
+ | `499` | Client Closed Request | Processing interrupted |
461
+ | `500` | Internal Server Error | Server-side processing error |
462
+
463
+ ### Common Error Scenarios
464
+
465
+ **File Too Large**
466
+ ```json
467
+ {
468
+ "success": false,
469
+ "message": "Validation error",
470
+ "error": "File size 12582912 bytes exceeds maximum 10485760 bytes",
471
+ "timestamp": "2024-12-19T14:32:15.123456"
472
+ }
473
+ ```
474
+
475
+ **Unsupported Format**
476
+ ```json
477
+ {
478
+ "success": false,
479
+ "message": "Validation error",
480
+ "error": "File extension .gif not allowed. Allowed: .jpg, .jpeg, .png, .webp",
481
+ "timestamp": "2024-12-19T14:32:15.123456"
482
+ }
483
+ ```
484
+
485
+ **Batch Not Found**
486
+ ```json
487
+ {
488
+ "success": false,
489
+ "message": "Batch not found",
490
+ "error": null,
491
+ "timestamp": "2024-12-19T14:32:15.123456"
492
+ }
493
+ ```
494
+
495
+ **Processing Timeout**
496
+ ```json
497
+ {
498
+ "success": false,
499
+ "message": "Processing timeout",
500
+ "error": "Image analysis exceeded 30 second timeout",
501
+ "timestamp": "2024-12-19T14:32:45.123456"
502
+ }
503
+ ```
504
+
505
+ ---
506
+
507
+ ## Rate Limits
508
+
509
+ **Current Version**: No rate limiting implemented
510
+
511
+ **Recommended Production Limits**:
512
+ - Single image analysis: **60 requests/minute per IP**
513
+ - Batch analysis: **10 requests/minute per IP**
514
+ - Report downloads: **30 requests/minute per IP**
515
+
516
+ ---
517
+
518
+ ## Data Models
519
+
520
+ ### MetricResult
521
+
522
+ ```typescript
523
+ {
524
+ metric_type: "gradient" | "frequency" | "noise" | "texture" | "color",
525
+ score: number, // 0.0 - 1.0
526
+ confidence: number, // 0.0 - 1.0
527
+ details: object // Metric-specific forensic data
528
+ }
529
+ ```
530
+
531
+ ### DetectionSignal
532
+
533
+ ```typescript
534
+ {
535
+ name: string,
536
+ metric_type: "gradient" | "frequency" | "noise" | "texture" | "color",
537
+ score: number, // 0.0 - 1.0
538
+ status: "passed" | "warning" | "flagged",
539
+ explanation: string
540
+ }
541
+ ```
542
+
543
+ ### AnalysisResult
544
+
545
+ ```typescript
546
+ {
547
+ filename: string,
548
+ status: "LIKELY_AUTHENTIC" | "REVIEW_REQUIRED",
549
+ overall_score: number, // 0.0 - 1.0
550
+ confidence: number, // 0 - 100
551
+ signals: DetectionSignal[],
552
+ metric_results: {
553
+ [key: string]: MetricResult
554
+ },
555
+ processing_time: number, // seconds
556
+ image_size: [number, number],
557
+ timestamp: string // ISO 8601 format
558
+ }
559
+ ```
560
+
561
+ ### BatchAnalysisResult
562
+
563
+ ```typescript
564
+ {
565
+ total_images: number,
566
+ processed: number,
567
+ failed: number,
568
+ results: AnalysisResult[],
569
+ summary: {
570
+ likely_authentic: number,
571
+ review_required: number,
572
+ success_rate: number, // percentage
573
+ processed: number,
574
+ failed: number,
575
+ avg_score: number,
576
+ avg_confidence: number,
577
+ avg_proc_time: number
578
+ },
579
+ total_processing_time: number,
580
+ timestamp: string
581
+ }
582
+ ```
583
+
584
+ ---
585
+
586
+ ## Usage Examples
587
+
588
+ ### Python
589
+
590
+ ```python
591
+ import requests
592
+
593
+ # Single image analysis
594
+ with open('image.jpg', 'rb') as f:
595
+ response = requests.post(
596
+ 'http://localhost:8005/analyze/image',
597
+ files={'file': f}
598
+ )
599
+ result = response.json()
600
+ print(f"Status: {result['data']['status']}")
601
+ print(f"Score: {result['data']['overall_score']}")
602
+
603
+ # Batch analysis
604
+ files = [
605
+ ('files', open('img1.jpg', 'rb')),
606
+ ('files', open('img2.png', 'rb')),
607
+ ('files', open('img3.webp', 'rb'))
608
+ ]
609
+ response = requests.post(
610
+ 'http://localhost:8005/analyze/batch',
611
+ files=files
612
+ )
613
+ batch_result = response.json()
614
+ batch_id = batch_result['data']['batch_id']
615
+
616
+ # Download CSV report
617
+ csv_response = requests.get(f'http://localhost:8005/report/csv/{batch_id}')
618
+ with open('report.csv', 'wb') as f:
619
+ f.write(csv_response.content)
620
+ ```
621
+
622
+ ### JavaScript (Node.js)
623
+
624
+ ```javascript
625
+ const FormData = require('form-data');
626
+ const fs = require('fs');
627
+ const axios = require('axios');
628
+
629
+ // Single image analysis
630
+ const form = new FormData();
631
+ form.append('file', fs.createReadStream('image.jpg'));
632
+
633
+ axios.post('http://localhost:8005/analyze/image', form, {
634
+ headers: form.getHeaders()
635
+ })
636
+ .then(response => {
637
+ console.log('Status:', response.data.data.status);
638
+ console.log('Score:', response.data.data.overall_score);
639
+ })
640
+ .catch(error => {
641
+ console.error('Error:', error.response.data);
642
+ });
643
+
644
+ // Batch analysis
645
+ const batchForm = new FormData();
646
+ batchForm.append('files', fs.createReadStream('img1.jpg'));
647
+ batchForm.append('files', fs.createReadStream('img2.png'));
648
+
649
+ axios.post('http://localhost:8005/analyze/batch', batchForm, {
650
+ headers: batchForm.getHeaders()
651
+ })
652
+ .then(response => {
653
+ const batchId = response.data.data.batch_id;
654
+ console.log('Batch ID:', batchId);
655
+
656
+ // Download PDF report
657
+ return axios.get(`http://localhost:8005/report/pdf/${batchId}`, {
658
+ responseType: 'arraybuffer'
659
+ });
660
+ })
661
+ .then(pdfResponse => {
662
+ fs.writeFileSync('report.pdf', pdfResponse.data);
663
+ console.log('Report downloaded');
664
+ });
665
+ ```
666
+
667
+ ### cURL
668
+
669
+ ```bash
670
+ # Single image
671
+ curl -X POST http://localhost:8005/analyze/image \
672
+ -F "file=@image.jpg" \
673
+ | jq '.data.status, .data.overall_score'
674
+
675
+ # Batch processing
676
+ curl -X POST http://localhost:8005/analyze/batch \
677
+ -F "files=@img1.jpg" \
678
+ -F "files=@img2.png" \
679
+ -F "files=@img3.webp" \
680
+ | jq '.data.batch_id'
681
+
682
+ # Progress tracking
683
+ curl -X GET http://localhost:8005/batch/{batch_id}/progress
684
+
685
+ # Download reports
686
+ curl -X GET http://localhost:8005/report/csv/{batch_id} -o report.csv
687
+ curl -X GET http://localhost:8005/report/pdf/{batch_id} -o report.pdf
688
+ ```
689
+
690
+ ---
691
+
692
+ ## Changelog
693
+
694
+ ### Version 1.0.0 (Current)
695
+ - Initial API release
696
+ - Single and batch image analysis
697
+ - CSV, JSON, PDF export
698
+ - Progress tracking
699
+ - Multi-metric ensemble detection
700
+
701
+ ### Planned Features
702
+ - API key authentication
703
+ - Webhook callbacks for async processing
704
+ - Custom threshold configuration per request
705
+ - Historical analysis lookup
706
+ - Metrics-only API endpoints
707
+
708
+ ---
709
+
710
+ *API Documentation Version: 1.0*
711
+ *Last Updated: December 2025*
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture Documentation
2
+
3
+ ## Table of Contents
4
+ 1. [System Overview](#system-overview)
5
+ 2. [Overall Architecture](#overall-architecture)
6
+ 3. [Data Pipeline](#data-pipeline)
7
+ 4. [Component Details](#component-details)
8
+ 5. [Product Architecture](#product-architecture)
9
+ 6. [Technology Stack](#technology-stack)
10
+
11
+ ---
12
+
13
+ ## System Overview
14
+
15
+ ImageScreenAI is a multi-metric ensemble system designed for first-pass screening of potentially AI-generated images in production workflows. The system processes images through five independent statistical detectors, aggregates their outputs, and provides actionable binary decisions with full explainability.
16
+
17
+ **Design Principles:**
18
+ - No single metric dominates decisions
19
+ - All intermediate data preserved for explainability
20
+ - Parallel processing for batch efficiency
21
+ - Zero external ML model dependencies
22
+ - Transparent, auditable decision logic
23
+
24
+ ---
25
+
26
+ ## Overall Architecture
27
+
28
+ ```mermaid
29
+ graph TB
30
+ subgraph "Frontend Layer"
31
+ UI[Web UI<br/>Single Page HTML]
32
+ end
33
+
34
+ subgraph "API Layer"
35
+ API[FastAPI Server<br/>app.py]
36
+ CORS[CORS Middleware]
37
+ ERROR[Error Handler]
38
+ end
39
+
40
+ subgraph "Processing Layer"
41
+ VALIDATOR[Image Validator<br/>utils/validators.py]
42
+ BATCH[Batch Processor<br/>features/batch_processor.py]
43
+ THRESH[Threshold Manager<br/>features/threshold_manager.py]
44
+ end
45
+
46
+ subgraph "Detection Layer"
47
+ AGG[Metrics Aggregator<br/>metrics/aggregator.py]
48
+
49
+ subgraph "Independent Metrics"
50
+ M1[Gradient PCA<br/>gradient_field_pca.py]
51
+ M2[Frequency FFT<br/>frequency_analyzer.py]
52
+ M3[Noise Pattern<br/>noise_analyzer.py]
53
+ M4[Texture Stats<br/>texture_analyzer.py]
54
+ M5[Color Distribution<br/>color_analyzer.py]
55
+ end
56
+ end
57
+
58
+ subgraph "Reporting Layer"
59
+ DETAIL[DetailedResultMaker<br/>features/detailed_result_maker.py]
60
+ CSV[CSV Reporter]
61
+ JSON[JSON Reporter]
62
+ PDF[PDF Reporter]
63
+ end
64
+
65
+ subgraph "Storage Layer"
66
+ UPLOAD[(Temp Upload<br/>data/uploads/)]
67
+ CACHE[(Cache<br/>data/cache/)]
68
+ REPORTS[(Reports<br/>data/reports/)]
69
+ end
70
+
71
+ UI --> API
72
+ API --> VALIDATOR
73
+ VALIDATOR --> BATCH
74
+ BATCH --> AGG
75
+ AGG --> M1 & M2 & M3 & M4 & M5
76
+ M1 & M2 & M3 & M4 & M5 --> AGG
77
+ AGG --> THRESH
78
+ THRESH --> DETAIL
79
+ DETAIL --> CSV & JSON & PDF
80
+
81
+ API -.-> UPLOAD
82
+ BATCH -.-> CACHE
83
+ CSV & JSON & PDF -.-> REPORTS
84
+
85
+ style UI fill:#e1f5ff
86
+ style API fill:#fff4e1
87
+ style AGG fill:#ffe1e1
88
+ style DETAIL fill:#e1ffe1
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Data Pipeline
94
+
95
+ ```mermaid
96
+ flowchart LR
97
+ subgraph "Input Stage"
98
+ A[Image Upload] --> B{Validation}
99
+ B -->|Pass| C[Temp Storage]
100
+ B -->|Fail| Z1[Error Response]
101
+ end
102
+
103
+ subgraph "Preprocessing"
104
+ C --> D[Load Image<br/>RGB Array]
105
+ D --> E[Resize if Needed<br/>max 1024px]
106
+ E --> F[Convert to<br/>Luminance]
107
+ end
108
+
109
+ subgraph "Parallel Metric Execution"
110
+ F --> G1[Gradient<br/>Analysis]
111
+ F --> G2[Frequency<br/>Analysis]
112
+ F --> G3[Noise<br/>Analysis]
113
+ F --> G4[Texture<br/>Analysis]
114
+ F --> G5[Color<br/>Analysis]
115
+ end
116
+
117
+ subgraph "Score Aggregation"
118
+ G1 --> H[Weighted<br/>Ensemble]
119
+ G2 --> H
120
+ G3 --> H
121
+ G4 --> H
122
+ G5 --> H
123
+ H --> I[Overall Score<br/>0.0 - 1.0]
124
+ end
125
+
126
+ subgraph "Decision Logic"
127
+ I --> J{Score vs<br/>Threshold}
128
+ J -->|>= 0.65| K1[REVIEW<br/>REQUIRED]
129
+ J -->|< 0.65| K2[LIKELY<br/>AUTHENTIC]
130
+ end
131
+
132
+ subgraph "Output Stage"
133
+ K1 --> L[Detailed Result<br/>Assembly]
134
+ K2 --> L
135
+ L --> M[Signal Status<br/>Per Metric]
136
+ M --> N[Explainability<br/>Generation]
137
+ N --> O[Report Export<br/>CSV/JSON/PDF]
138
+ end
139
+
140
+ style B fill:#ffcccc
141
+ style H fill:#cce5ff
142
+ style J fill:#ffffcc
143
+ style O fill:#ccffcc
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Component Details
149
+
150
+ ### 1. Configuration Layer (`config/`)
151
+
152
+ ```mermaid
153
+ classDiagram
154
+ class Settings {
155
+ +str APP_NAME
156
+ +float REVIEW_THRESHOLD
157
+ +dict METRIC_WEIGHTS
158
+ +int MAX_WORKERS
159
+ +get_metric_weights()
160
+ +_validate_weights()
161
+ }
162
+
163
+ class Constants {
164
+ <<enumeration>>
165
+ +MetricType
166
+ +SignalStatus
167
+ +DetectionStatus
168
+ +SIGNAL_THRESHOLDS
169
+ +METRIC_EXPLANATIONS
170
+ }
171
+
172
+ class Schemas {
173
+ +MetricResult
174
+ +DetectionSignal
175
+ +AnalysisResult
176
+ +BatchAnalysisResult
177
+ }
178
+
179
+ Settings --> Constants: uses
180
+ Schemas --> Constants: references
181
+ ```
182
+
183
+ **Key Configuration Files:**
184
+ - `settings.py`: Runtime settings, environment variables, validation
185
+ - `constants.py`: Enums, thresholds, metric parameters, explanations
186
+ - `schemas.py`: Pydantic models for type safety and validation
187
+
188
+ ---
189
+
190
+ ### 2. Metrics Layer (`metrics/`)
191
+
192
+ ```mermaid
193
+ graph TD
194
+ subgraph "Gradient-Field PCA"
195
+ A1[RGB → Luminance] --> A2[Sobel Gradients]
196
+ A2 --> A3[Sample Vectors<br/>n=10000]
197
+ A3 --> A4[PCA Analysis]
198
+ A4 --> A5[Eigenvalue Ratio]
199
+ A5 --> A6{Ratio < 0.85?}
200
+ A6 -->|Yes| A7[High Suspicion]
201
+ A6 -->|No| A8[Low Suspicion]
202
+ end
203
+
204
+ subgraph "Frequency Analysis"
205
+ B1[Luminance] --> B2[2D FFT]
206
+ B2 --> B3[Radial Spectrum<br/>64 bins]
207
+ B3 --> B4[HF Energy Ratio]
208
+ B4 --> B5[Spectral Roughness]
209
+ B5 --> B6[Power Law Deviation]
210
+ B6 --> B7[Weighted Anomaly]
211
+ end
212
+
213
+ subgraph "Noise Analysis"
214
+ C1[Luminance] --> C2[Extract Patches<br/>32×32, stride=16]
215
+ C2 --> C3[Laplacian Filter]
216
+ C3 --> C4[MAD Estimation]
217
+ C4 --> C5[CV Analysis]
218
+ C5 --> C6[IQR Analysis]
219
+ C6 --> C7[Uniformity Score]
220
+ end
221
+
222
+ style A1 fill:#ffe1e1
223
+ style B1 fill:#e1e1ff
224
+ style C1 fill:#e1ffe1
225
+ ```
226
+
227
+ **Metric Weights (Default):**
228
+ ```
229
+ Gradient: 30%
230
+ Frequency: 25%
231
+ Noise: 20%
232
+ Texture: 15%
233
+ Color: 10%
234
+ ```
235
+
236
+ ---
237
+
238
+ ### 3. Processing Pipeline
239
+
240
+ ```mermaid
241
+ sequenceDiagram
242
+ participant UI
243
+ participant API
244
+ participant BatchProcessor
245
+ participant MetricsAggregator
246
+ participant Metric1
247
+ participant Metric2
248
+ participant ThresholdManager
249
+ participant DetailedResultMaker
250
+
251
+ UI->>API: Upload Batch (n images)
252
+ API->>BatchProcessor: process_batch()
253
+
254
+ loop For Each Image
255
+ BatchProcessor->>MetricsAggregator: analyze_image()
256
+
257
+ par Parallel Execution
258
+ MetricsAggregator->>Metric1: detect()
259
+ MetricsAggregator->>Metric2: detect()
260
+ end
261
+
262
+ Metric1-->>MetricsAggregator: MetricResult(score, confidence, details)
263
+ Metric2-->>MetricsAggregator: MetricResult(score, confidence, details)
264
+
265
+ MetricsAggregator->>MetricsAggregator: _aggregate_scores()
266
+ MetricsAggregator->>ThresholdManager: _determine_status()
267
+ ThresholdManager-->>MetricsAggregator: DetectionStatus
268
+
269
+ MetricsAggregator-->>BatchProcessor: AnalysisResult
270
+ BatchProcessor->>UI: Progress Update
271
+ end
272
+
273
+ BatchProcessor->>DetailedResultMaker: extract_detailed_results()
274
+ DetailedResultMaker-->>BatchProcessor: Detailed Report Data
275
+
276
+ BatchProcessor-->>API: BatchAnalysisResult
277
+ API-->>UI: JSON Response + batch_id
278
+ ```
279
+
280
+ ---
281
+
282
+ ### 4. Metric Execution Detail
283
+
284
+ ```mermaid
285
+ flowchart TB
286
+ subgraph "Single Metric Execution"
287
+ A[Input: RGB Image<br/>H×W×3] --> B[Preprocessing<br/>Normalization/Conversion]
288
+
289
+ B --> C[Feature Extraction]
290
+
291
+ C --> D1[Sub-metric 1]
292
+ C --> D2[Sub-metric 2]
293
+ C --> D3[Sub-metric 3]
294
+
295
+ D1 --> E[Sub-score 1<br/>0.0 - 1.0]
296
+ D2 --> F[Sub-score 2<br/>0.0 - 1.0]
297
+ D3 --> G[Sub-score 3<br/>0.0 - 1.0]
298
+
299
+ E --> H[Weighted Combination]
300
+ F --> H
301
+ G --> H
302
+
303
+ H --> I[Final Metric Score]
304
+ I --> J[Confidence Calculation]
305
+
306
+ J --> K[MetricResult Object]
307
+ K --> L{Valid?}
308
+ L -->|Yes| M[Return to Aggregator]
309
+ L -->|No| N[Return Neutral Score<br/>0.5 + 0 confidence]
310
+ end
311
+
312
+ style A fill:#e1f5ff
313
+ style I fill:#ffe1e1
314
+ style K fill:#e1ffe1
315
+ ```
316
+
317
+ **Example: Noise Analysis Sub-metrics**
318
+ - CV Anomaly: 40% weight
319
+ - Noise Level Anomaly: 40% weight
320
+ - IQR Anomaly: 20% weight
321
+
322
+ ---
323
+
324
+ ## Product Architecture
325
+
326
+ ```mermaid
327
+ graph TB
328
+ subgraph "User Interfaces"
329
+ WEB[Web UI<br/>Browser-based]
330
+ API_CLIENT[API Clients<br/>Programmatic Access]
331
+ end
332
+
333
+ subgraph "Core Engine"
334
+ SCREEN[Screening Engine<br/>Multi-metric Ensemble]
335
+ THRESH_MGR[Threshold Manager<br/>Sensitivity Control]
336
+ end
337
+
338
+ subgraph "Reporting System"
339
+ DETAIL[Detailed Analysis]
340
+ EXPORT[Multi-format Export<br/>CSV/JSON/PDF]
341
+ end
342
+
343
+ subgraph "Use Cases"
344
+ UC1[Content Moderation<br/>Pipelines]
345
+ UC2[Journalism<br/>Verification]
346
+ UC3[Stock Photo<br/>Platforms]
347
+ UC4[Legal/Compliance<br/>Workflows]
348
+ end
349
+
350
+ WEB --> SCREEN
351
+ API_CLIENT --> SCREEN
352
+
353
+ SCREEN --> THRESH_MGR
354
+ THRESH_MGR --> DETAIL
355
+ DETAIL --> EXPORT
356
+
357
+ EXPORT -.->|Feeds| UC1
358
+ EXPORT -.->|Feeds| UC2
359
+ EXPORT -.->|Feeds| UC3
360
+ EXPORT -.->|Feeds| UC4
361
+
362
+ style SCREEN fill:#ff6b6b
363
+ style EXPORT fill:#4ecdc4
364
+ style UC1 fill:#ffe66d
365
+ style UC2 fill:#ffe66d
366
+ style UC3 fill:#ffe66d
367
+ style UC4 fill:#ffe66d
368
+ ```
369
+
370
+ ---
371
+
372
+ ## Technology Stack
373
+
374
+ ```mermaid
375
+ graph LR
376
+ subgraph "Backend"
377
+ B1[Python 3.11+]
378
+ B2[FastAPI]
379
+ B3[Pydantic]
380
+ B4[NumPy/SciPy]
381
+ B5[OpenCV]
382
+ B6[Pillow]
383
+ end
384
+
385
+ subgraph "Frontend"
386
+ F1[HTML5]
387
+ F2[Vanilla JavaScript]
388
+ F3[CSS3]
389
+ end
390
+
391
+ subgraph "Reporting"
392
+ R1[ReportLab PDF]
393
+ R2[CSV stdlib]
394
+ R3[JSON stdlib]
395
+ end
396
+
397
+ subgraph "Infrastructure"
398
+ I1[Uvicorn ASGI]
399
+ I2[File-based Storage]
400
+ I3[In-memory Sessions]
401
+ end
402
+
403
+ B2 --> B1
404
+ B3 --> B1
405
+ B4 --> B1
406
+ B5 --> B1
407
+ B6 --> B1
408
+
409
+ F1 --> F2
410
+ F2 --> F3
411
+
412
+ R1 --> B1
413
+ R2 --> B1
414
+ R3 --> B1
415
+
416
+ I1 --> B2
417
+ I2 --> B1
418
+ I3 --> B2
419
+
420
+ style B1 fill:#3776ab
421
+ style B2 fill:#009688
422
+ style F1 fill:#e34c26
423
+ style F2 fill:#f0db4f
424
+ ```
425
+
426
+ **Key Dependencies:**
427
+ - **FastAPI**: Async API framework
428
+ - **NumPy/SciPy**: Numerical computation
429
+ - **OpenCV**: Image processing and filtering
430
+ - **Pillow**: Image loading and validation
431
+ - **ReportLab**: PDF generation
432
+ - **Pydantic**: Data validation and serialization
433
+
434
+ ---
435
+
436
+ ## Performance Characteristics
437
+
438
+ ### Processing Times (Average)
439
+ - Single image analysis: **2-4 seconds**
440
+ - Batch processing (10 images): **15-25 seconds** (parallel)
441
+ - Report generation: **1-3 seconds**
442
+
443
+ ### Resource Usage
444
+ - Memory per image: **50-150 MB**
445
+ - Max concurrent workers: **4** (configurable)
446
+ - Temp storage: **~10 MB per image**
447
+
448
+ ### Scalability Considerations
449
+ - **Current**: Single-server deployment
450
+ - **Bottleneck**: CPU-bound metric computation
451
+ - **Future**: Distributed processing via task queue (Celery/RabbitMQ)
452
+
453
+ ---
454
+
455
+ ## Security & Privacy
456
+
457
+ 1. **No data persistence**: Uploaded images deleted after processing
458
+ 2. **Local processing**: No external API calls
459
+ 3. **Stateless design**: No user tracking
460
+ 4. **Input validation**: File type, size, dimension checks
461
+ 5. **Timeout protection**: 30s per-image limit
462
+
463
+ ---
464
+
465
+ ## Deployment Architecture
466
+
467
+ ```mermaid
468
+ graph TB
469
+ subgraph "Production Deployment"
470
+ LB[Load Balancer<br/>Nginx/Traefik]
471
+
472
+ subgraph "Application Servers"
473
+ APP1[FastAPI Instance 1<br/>4 workers]
474
+ APP2[FastAPI Instance 2<br/>4 workers]
475
+ end
476
+
477
+ subgraph "Shared Storage"
478
+ NFS[Shared NFS Mount<br/>reports/ cache/]
479
+ end
480
+
481
+ subgraph "Monitoring"
482
+ LOGS[Log Aggregation<br/>ELK/Loki]
483
+ METRICS[Metrics<br/>Prometheus]
484
+ end
485
+ end
486
+
487
+ CLIENT[Clients] --> LB
488
+ LB --> APP1
489
+ LB --> APP2
490
+
491
+ APP1 -.-> NFS
492
+ APP2 -.-> NFS
493
+
494
+ APP1 -.-> LOGS
495
+ APP2 -.-> LOGS
496
+
497
+ APP1 -.-> METRICS
498
+ APP2 -.-> METRICS
499
+
500
+ style LB fill:#4ecdc4
501
+ style APP1 fill:#ff6b6b
502
+ style APP2 fill:#ff6b6b
503
+ style NFS fill:#95e1d3
504
+ ```
505
+
506
+ **Recommended Setup:**
507
+ - **Web Server**: Nginx (reverse proxy)
508
+ - **App Server**: Uvicorn (ASGI)
509
+ - **Process Manager**: Systemd or Supervisor
510
+ - **Monitoring**: Prometheus + Grafana
511
+ - **Logging**: Structured JSON logs to ELK stack
512
+
513
+ ---
514
+
515
+ *Document Version: 1.0*
516
+ *Last Updated: December 2025*
features/__init__.py ADDED
File without changes
features/batch_processor.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import time
3
+ from typing import List
4
+ from typing import Dict
5
+ from typing import Tuple
6
+ from pathlib import Path
7
+ from typing import Callable
8
+ from utils.logger import get_logger
9
+ from config.settings import settings
10
+ from config.schemas import AnalysisResult
11
+ from concurrent.futures import TimeoutError
12
+ from concurrent.futures import as_completed
13
+ from config.constants import DetectionStatus
14
+ from config.schemas import BatchAnalysisResult
15
+ from metrics.aggregator import MetricsAggregator
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from features.threshold_manager import ThresholdManager
18
+
19
+
20
+ # Setup Logging
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ class BatchProcessor:
25
+ """
26
+ Process multiple images in parallel or sequential mode
27
+
28
+ Features:
29
+ ---------
30
+ - Parallel processing using ThreadPoolExecutor
31
+ - Sequential fallback for single images or disabled parallel mode
32
+ - Automatic error handling and recovery
33
+ - Progress tracking and logging
34
+ """
35
+ def __init__(self, threshold_manager: ThresholdManager):
36
+ """
37
+ Initialize Batch Processor
38
+ """
39
+ # Instantiate threshold manager
40
+ self.threshold_manager = threshold_manager
41
+
42
+ # Initialize aggregator
43
+ self.aggregator = MetricsAggregator(threshold_manager = threshold_manager)
44
+
45
+ # Fix number of workers
46
+ self.max_workers = settings.MAX_WORKERS if settings.PARALLEL_PROCESSING else 1
47
+
48
+ logger.info(f"BatchProcessor initialized with max_workers={self.max_workers}, parallel={settings.PARALLEL_PROCESSING}")
49
+
50
+
51
+ def process_batch(self, image_files: List[Dict[str, any]], on_progress: Callable[[int, int, str], None] | None = None) -> BatchAnalysisResult:
52
+ """
53
+ Process multiple images with automatic parallel/sequential switching
54
+
55
+ Arguments:
56
+ ----------
57
+ image_files { list } : List of dicts with keys:
58
+ - 'path' : Path object
59
+ - 'filename' : str
60
+ - 'size' : tuple (width, height)
61
+
62
+ on_progress { Callablel } : Optional callback invoked after each image is processed
63
+
64
+ Returns:
65
+ --------
66
+ { BatchAnalysisResult } : Complete batch analysis result
67
+ """
68
+ start_time = time.time()
69
+ total_images = len(image_files)
70
+
71
+ logger.info(f"Starting batch processing of {total_images} images")
72
+
73
+ # Validate input
74
+ if (total_images == 0):
75
+ logger.warning("Empty batch provided")
76
+ return self._create_empty_batch_result()
77
+
78
+ if (total_images > settings.MAX_BATCH_SIZE):
79
+ logger.error(f"Batch size {total_images} exceeds maximum {settings.MAX_BATCH_SIZE}")
80
+ raise ValueError(f"Batch size {total_images} exceeds maximum allowed {settings.MAX_BATCH_SIZE}")
81
+
82
+ # Choose processing strategy
83
+ if (settings.PARALLEL_PROCESSING and (total_images > 1)):
84
+ results, failed = self._process_parallel(image_files = image_files,
85
+ on_progress = on_progress,
86
+ )
87
+
88
+ else:
89
+ results, failed = self._process_sequential(image_files = image_files,
90
+ on_progress = on_progress,
91
+ )
92
+
93
+ total_time = time.time() - start_time
94
+
95
+ # Create batch result
96
+ batch_result = BatchAnalysisResult(total_images = total_images,
97
+ processed = len(results),
98
+ failed = failed,
99
+ results = results,
100
+ total_processing_time = total_time,
101
+ )
102
+
103
+ # Calculate summary statistics
104
+ batch_result.summary = self._calculate_summary(results = results,
105
+ total = total_images,
106
+ )
107
+
108
+ logger.info(f"Batch processing complete: {len(results)}/{total_images} successful, {failed} failed in {total_time:.2f}s")
109
+
110
+ return batch_result
111
+
112
+
113
+ def _process_parallel(self, image_files: List[Dict], on_progress: Callable[[int, int, str], None] | None = None) -> Tuple[List[AnalysisResult], int]:
114
+ """
115
+ Process images in parallel using ThreadPoolExecutor
116
+
117
+ Arguments:
118
+ ----------
119
+ image_files { list } : List of image file dictionaries
120
+
121
+ on_progress { Callablel } : Optional callback invoked after each image is processed
122
+
123
+ Returns:
124
+ --------
125
+ { tuple } : (results_list, failed_count)
126
+ """
127
+ results = list()
128
+ failed = 0
129
+
130
+ logger.debug(f"Using parallel processing with {self.max_workers} workers")
131
+
132
+ with ThreadPoolExecutor(max_workers = self.max_workers) as executor:
133
+ # Submit all tasks
134
+ future_to_file = {executor.submit(self.process_single,
135
+ image['path'],
136
+ image['filename'],
137
+ image['size'],
138
+ ): image for image in image_files
139
+ }
140
+
141
+ # Collect results as they complete
142
+ completed = 0
143
+
144
+ for future in as_completed(future_to_file):
145
+ completed += 1
146
+ image = future_to_file[future]
147
+
148
+ if on_progress:
149
+ on_progress(completed, len(image_files), image["filename"])
150
+
151
+ try:
152
+ result = future.result(timeout = settings.PROCESSING_TIMEOUT)
153
+
154
+ if result:
155
+ results.append(result)
156
+ logger.debug(f"✓ Completed: {image['filename']}")
157
+
158
+ else:
159
+ failed += 1
160
+ logger.warning(f"✗ Failed: {image['filename']} (returned None)")
161
+
162
+ except TimeoutError:
163
+ failed += 1
164
+ logger.error(f"✗ Timeout: {image['filename']} (exceeded {settings.PROCESSING_TIMEOUT}s)")
165
+
166
+ except Exception as e:
167
+ failed += 1
168
+ logger.error(f"✗ Error: {image['filename']} - {e}")
169
+
170
+ return results, failed
171
+
172
+
173
+ def _process_sequential(self, image_files: List[Dict], on_progress: Callable[[int, int, str], None] | None = None) -> Tuple[List[AnalysisResult], int]:
174
+ """
175
+ Process images sequentially (fallback or single image)
176
+
177
+ Arguments:
178
+ ----------
179
+ image_files { list } : List of image file dictionaries
180
+
181
+ on_progress { Callabel } : Optional callback invoked after each image is processed
182
+
183
+ Returns:
184
+ --------
185
+ { tuple } : (results_list, failed_count)
186
+ """
187
+ results = list()
188
+ failed = 0
189
+
190
+ logger.debug("Using sequential processing")
191
+
192
+ for idx, image in enumerate(image_files, 1):
193
+ try:
194
+ if on_progress:
195
+ on_progress(idx, len(image_files), image["filename"])
196
+
197
+ result = self.process_single(image_path = image['path'],
198
+ filename = image['filename'],
199
+ image_size = image['size'],
200
+ )
201
+
202
+ if result:
203
+ results.append(result)
204
+ logger.debug(f"✓ Completed: {image['filename']}")
205
+
206
+ else:
207
+ failed += 1
208
+ logger.warning(f"✗ Failed: {image['filename']} (returned None)")
209
+
210
+ except Exception as e:
211
+ failed += 1
212
+ logger.error(f"✗ Error: {image['filename']} - {e}")
213
+
214
+ return results, failed
215
+
216
+
217
+ def process_single(self, image_path: Path, filename: str, image_size: Tuple[int, int]) -> AnalysisResult:
218
+ """
219
+ Process single image (called by both parallel and sequential)
220
+
221
+ Arguments:
222
+ ----------
223
+ image_path { Path } : Path to image file
224
+
225
+ filename { str } : Original filename
226
+
227
+ image_size { tuple } : (width, height)
228
+
229
+ Returns:
230
+ --------
231
+ { AnalysisResult } : Analysis result or None on error
232
+ """
233
+ try:
234
+ return self.aggregator.analyze_image(image_path = image_path,
235
+ filename = filename,
236
+ image_size = image_size,
237
+ )
238
+
239
+ except Exception as e:
240
+ logger.error(f"Failed to process {filename}: {e}", exc_info = True)
241
+ return None
242
+
243
+
244
+ def _calculate_summary(self, results: List[AnalysisResult], total: int) -> Dict[str, int]:
245
+ """
246
+ Calculate summary statistics from results
247
+
248
+ Arguments:
249
+ ----------
250
+ results { list } : List of analysis results
251
+
252
+ total { int } : Total number of images
253
+
254
+ Returns:
255
+ --------
256
+ { dict } : Summary statistics
257
+ """
258
+ # Calculate processing stats
259
+ likely_authentic = sum(1 for r in results if (r.status == DetectionStatus.LIKELY_AUTHENTIC))
260
+ review_required = sum(1 for r in results if (r.status == DetectionStatus.REVIEW_REQUIRED))
261
+
262
+ processed = len(results)
263
+ failed = total - processed
264
+ success_rate = int((processed / total * 100) if (total > 0) else 0)
265
+
266
+ # Calculate average scores
267
+ avg_score = sum(r.overall_score for r in results) / len(results) if results else 0.0
268
+ avg_confidence = sum(r.confidence for r in results) / len(results) if results else 0
269
+ avg_proc_time = sum(r.processing_time for r in results) / len(results) if results else 0.0
270
+
271
+ return {"likely_authentic" : likely_authentic,
272
+ "review_required" : review_required,
273
+ "success_rate" : success_rate,
274
+ "processed" : processed,
275
+ "failed" : failed,
276
+ "avg_score" : round(avg_score, 3),
277
+ "avg_confidence" : int(avg_confidence),
278
+ "avg_proc_time" : round(avg_proc_time, 2),
279
+ }
280
+
281
+
282
+ def _create_empty_batch_result(self) -> BatchAnalysisResult:
283
+ """
284
+ Create empty batch result for edge cases
285
+
286
+ Returns:
287
+ --------
288
+ { BatchAnalysisResult } : Empty batch result
289
+ """
290
+ return BatchAnalysisResult(total_images = 0,
291
+ processed = 0,
292
+ failed = 0,
293
+ results = [],
294
+ summary = {"likely_authentic" : 0,
295
+ "review_required" : 0,
296
+ "success_rate" : 0,
297
+ },
298
+ total_processing_time = 0.0,
299
+ )
features/detailed_result_maker.py ADDED
@@ -0,0 +1,481 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import pandas as pd
3
+ from typing import Dict
4
+ from typing import List
5
+ from typing import Optional
6
+ from utils.logger import get_logger
7
+ from config.constants import MetricType
8
+ from config.constants import SignalStatus
9
+ from config.schemas import AnalysisResult
10
+ from config.constants import SIGNAL_THRESHOLDS
11
+
12
+
13
+ # Setup Logging
14
+ logger = get_logger(__name__)
15
+
16
+
17
+ class DetailedResultMaker:
18
+ """
19
+ Extract and format detailed analysis results for UI and reporting
20
+
21
+ Purpose:
22
+ --------
23
+ - Extracts all intermediate metrics from MetricResult objects
24
+ - Formats data for tabular display in UI
25
+ - Provides rich metadata for PDF/CSV reports
26
+ - No re-computation - just data extraction and formatting
27
+
28
+ Output Formats:
29
+ ---------------
30
+ 1. Structured dictionaries for UI
31
+ 2. Pandas DataFrames for reports
32
+ 3. Hierarchical JSON for API
33
+ """
34
+ def __init__(self, signal_thresholds: dict | None = None):
35
+ """
36
+ Initialize Detailed Result Maker
37
+ """
38
+ self.metric_display_names = {MetricType.GRADIENT : "Gradient-Field PCA",
39
+ MetricType.FREQUENCY : "Frequency Domain (FFT)",
40
+ MetricType.NOISE : "Noise Pattern Analysis",
41
+ MetricType.TEXTURE : "Texture Statistics",
42
+ MetricType.COLOR : "Color Distribution",
43
+ }
44
+
45
+ self.signal_thresholds = signal_thresholds or SIGNAL_THRESHOLDS
46
+
47
+ logger.debug("DetailedResultMaker initialized")
48
+
49
+
50
+ def extract_detailed_results(self, analysis_result: AnalysisResult) -> Dict:
51
+ """
52
+ Extract all detailed results from AnalysisResult
53
+
54
+ Arguments:
55
+ ----------
56
+ analysis_result { AnalysisResult } : Complete analysis result
57
+
58
+ Returns:
59
+ --------
60
+ { dict } : Comprehensive detailed results
61
+ """
62
+ logger.debug(f"Extracting detailed results for: {analysis_result.filename}")
63
+
64
+ detailed = {"filename" : analysis_result.filename,
65
+ "overall_summary" : self._extract_overall_summary(analysis_result = analysis_result),
66
+ "metrics_detailed" : self._extract_all_metrics(analysis_result = analysis_result),
67
+ "metadata" : self._extract_metadata(analysis_result = analysis_result),
68
+ }
69
+
70
+ logger.debug(f"Extracted {len(detailed['metrics_detailed'])} metric details")
71
+
72
+ return detailed
73
+
74
+
75
+ def create_detailed_table(self, analysis_result: AnalysisResult) -> pd.DataFrame:
76
+ """
77
+ Create detailed metrics table as DataFrame
78
+
79
+ Arguments:
80
+ ----------
81
+ analysis_result { AnalysisResult } : Complete analysis result
82
+
83
+ Returns:
84
+ --------
85
+ { DataFrame } : Tabular detailed results
86
+ """
87
+ rows = list()
88
+
89
+ for metric_type, metric_result in analysis_result.metric_results.items():
90
+ display_name = self.metric_display_names.get(metric_type, metric_type.value)
91
+
92
+ row = {"Metric" : display_name,
93
+ "Score" : round(metric_result.score, 3),
94
+ "Confidence" : round(metric_result.confidence, 3) if metric_result.confidence is not None else "N/A",
95
+ "Status" : self._score_to_status(score = metric_result.score),
96
+ }
97
+
98
+ # Add key details from each metric
99
+ details = self._extract_key_details(metric_type = metric_type,
100
+ metric_result = metric_result,
101
+ )
102
+
103
+ row.update(details)
104
+ rows.append(row)
105
+
106
+ # Dump rows into a pandas dataframe for structured result
107
+ dataframe = pd.DataFrame(data = rows)
108
+
109
+ logger.debug(f"Created detailed table with {len(dataframe)} rows, {len(dataframe.columns)} columns")
110
+
111
+ return dataframe
112
+
113
+
114
+ def create_report_data(self, analysis_result: AnalysisResult) -> Dict:
115
+ """
116
+ Create rich data structure for report generation
117
+
118
+ Arguments:
119
+ ----------
120
+ analysis_result { AnalysisResult } : Complete analysis result
121
+
122
+ Returns:
123
+ --------
124
+ { dict } : Report-ready data structure
125
+ """
126
+ report_data = {"header" : self._create_report_header(analysis_result = analysis_result),
127
+ "overall_assessment" : self._create_overall_assessment(analysis_result = analysis_result),
128
+ "metric_breakdown" : self._create_metric_breakdown(analysis_result = analysis_result),
129
+ "forensic_details" : self._create_forensic_details(analysis_result = analysis_result),
130
+ "recommendations" : self._create_recommendations(analysis_result = analysis_result),
131
+ }
132
+
133
+ logger.debug(f"Created report data for: {analysis_result.filename}")
134
+
135
+ return report_data
136
+
137
+
138
+ def _extract_overall_summary(self, analysis_result: AnalysisResult) -> Dict:
139
+ """
140
+ Extract overall summary information
141
+ """
142
+ timestamp = getattr(analysis_result, "timestamp", None)
143
+
144
+ return {"filename" : analysis_result.filename,
145
+ "status" : analysis_result.status.value,
146
+ "overall_score" : round(analysis_result.overall_score, 3),
147
+ "confidence" : analysis_result.confidence,
148
+ "processing_time" : round(analysis_result.processing_time, 2),
149
+ "image_size" : f"{analysis_result.image_size[0]}×{analysis_result.image_size[1]}",
150
+ "timestamp" : timestamp.isoformat() if timestamp else None,
151
+ }
152
+
153
+
154
+ def _extract_all_metrics(self, analysis_result: AnalysisResult) -> List[Dict]:
155
+ """
156
+ Extract detailed information for all metrics
157
+ """
158
+ metrics_detailed = list()
159
+
160
+ for metric_type, metric_result in analysis_result.metric_results.items():
161
+ metric_detail = {"metric_type" : metric_type.value,
162
+ "display_name" : self.metric_display_names.get(metric_type, metric_type.value),
163
+ "score" : round(metric_result.score, 3),
164
+ "confidence" : round(metric_result.confidence, 3) if metric_result.confidence is not None else None,
165
+ "status" : self._score_to_status(score = metric_result.score),
166
+ "details" : metric_result.details or {},
167
+ "interpretation" : self._interpret_metric(metric_type = metric_type,
168
+ metric_result = metric_result,
169
+ ),
170
+ }
171
+
172
+ metrics_detailed.append(metric_detail)
173
+
174
+ # Sort by score (highest first)
175
+ metrics_detailed.sort(key = lambda x: x['score'], reverse = True)
176
+
177
+ return metrics_detailed
178
+
179
+
180
+ def _extract_metadata(self, analysis_result: AnalysisResult) -> Dict:
181
+ """
182
+ Extract processing metadata
183
+ """
184
+ return {"total_metrics" : len(analysis_result.metric_results),
185
+ "flagged_metrics" : sum(1 for s in analysis_result.signals if s.status.value == 'flagged'),
186
+ "warning_metrics" : sum(1 for s in analysis_result.signals if s.status.value == 'warning'),
187
+ "passed_metrics" : sum(1 for s in analysis_result.signals if s.status.value == 'passed'),
188
+ "avg_confidence" : self._calculate_avg_confidence(analysis_result = analysis_result),
189
+ }
190
+
191
+
192
+ def _extract_key_details(self, metric_type: MetricType, metric_result) -> Dict:
193
+ """
194
+ Extract key details specific to each metric type
195
+ """
196
+ details = metric_result.details or {}
197
+
198
+ if (metric_type == MetricType.GRADIENT):
199
+ return {"Eigenvalue_Ratio" : details.get('eigenvalue_ratio', 'N/A'),
200
+ "Vectors_Sampled" : details.get('gradient_vectors_sampled', 'N/A'),
201
+ }
202
+
203
+ elif (metric_type == MetricType.FREQUENCY):
204
+ return {"HF_Ratio" : details.get('hf_ratio', 'N/A'),
205
+ "HF_Anomaly" : details.get('hf_anomaly', 'N/A'),
206
+ "Spectrum_Bins" : details.get('spectrum_bins', 'N/A'),
207
+ }
208
+
209
+ elif (metric_type == MetricType.NOISE):
210
+ return {"Mean_Noise" : details.get('mean_noise', 'N/A'),
211
+ "CV" : details.get('cv', 'N/A'),
212
+ "Patches_Valid" : details.get('patches_valid', 'N/A'),
213
+ }
214
+
215
+ elif (metric_type == MetricType.TEXTURE):
216
+ return {"Smooth_Ratio" : details.get('smooth_ratio', 'N/A'),
217
+ "Contrast_Mean" : details.get('contrast_mean', 'N/A'),
218
+ "Patches_Used" : details.get('patches_used', 'N/A'),
219
+ }
220
+
221
+ elif (metric_type == MetricType.COLOR):
222
+ sat_stats = details.get('saturation_stats', {})
223
+ return {"Mean_Saturation" : sat_stats.get('mean_saturation', 'N/A'),
224
+ "High_Sat_Ratio" : sat_stats.get('high_sat_ratio', 'N/A'),
225
+ }
226
+
227
+ return {}
228
+
229
+
230
+ def _interpret_metric(self, metric_type: MetricType, metric_result) -> str:
231
+ """
232
+ Provide human-readable interpretation of metric result
233
+ """
234
+ score = metric_result.score
235
+ details = metric_result.details or {}
236
+
237
+ if (metric_type == MetricType.GRADIENT):
238
+ eig_ratio = details.get('eigenvalue_ratio')
239
+
240
+ if eig_ratio:
241
+ return f"Eigenvalue ratio of {eig_ratio:.3f} ({'high' if eig_ratio > 0.85 else 'low'} alignment)"
242
+
243
+ return "Gradient structure analysis"
244
+
245
+ elif (metric_type == MetricType.FREQUENCY):
246
+ hf_ratio = details.get('hf_ratio')
247
+
248
+ if hf_ratio:
249
+ return f"High-freq ratio: {hf_ratio:.3f} ({'elevated' if hf_ratio > 0.35 else 'low' if hf_ratio < 0.08 else 'normal'})"
250
+
251
+ return "Frequency spectrum analysis"
252
+
253
+ elif (metric_type == MetricType.NOISE):
254
+ mean_noise = details.get('mean_noise')
255
+
256
+ if mean_noise:
257
+ return f"Mean noise: {mean_noise:.2f} ({'low' if mean_noise < 1.5 else 'normal'})"
258
+
259
+ return "Noise pattern analysis"
260
+
261
+ elif (metric_type == MetricType.TEXTURE):
262
+ smooth_ratio = details.get('smooth_ratio')
263
+
264
+ if smooth_ratio is not None:
265
+ return f"Smooth regions: {smooth_ratio:.1%} ({'excessive' if smooth_ratio > 0.4 else 'normal'})"
266
+
267
+ return "Texture variation analysis"
268
+
269
+ elif (metric_type == MetricType.COLOR):
270
+ sat_stats = details.get('saturation_stats', {})
271
+ mean_sat = sat_stats.get('mean_saturation')
272
+
273
+ if mean_sat:
274
+ return f"Mean saturation: {mean_sat:.2f} ({'high' if mean_sat > 0.65 else 'normal'})"
275
+
276
+ return "Color distribution analysis"
277
+
278
+ return "Analysis complete"
279
+
280
+
281
+ def _create_report_header(self, analysis_result: AnalysisResult) -> Dict:
282
+ """
283
+ Create report header section
284
+ """
285
+ return {"filename" : analysis_result.filename,
286
+ "analysis_date" : analysis_result.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
287
+ "image_size" : f"{analysis_result.image_size[0]} × {analysis_result.image_size[1]} pixels",
288
+ "processing_time" : f"{analysis_result.processing_time:.2f} seconds",
289
+ }
290
+
291
+
292
+ def _create_overall_assessment(self, analysis_result: AnalysisResult) -> Dict:
293
+ """
294
+ Create overall assessment section
295
+ """
296
+ return {"status" : analysis_result.status.value,
297
+ "score" : round(analysis_result.overall_score * 100, 1),
298
+ "confidence" : analysis_result.confidence,
299
+ "verdict" : "REVIEW REQUIRED" if analysis_result.status.value == "REVIEW_REQUIRED" else "LIKELY AUTHENTIC",
300
+ "risk_level" : self._calculate_risk_level(score = analysis_result.overall_score),
301
+ }
302
+
303
+
304
+ def _create_metric_breakdown(self, analysis_result: AnalysisResult) -> List[Dict]:
305
+ """
306
+ Create detailed metric breakdown for report
307
+ """
308
+ breakdown = list()
309
+
310
+ for signal in analysis_result.signals:
311
+ metric_result = analysis_result.metric_results.get(signal.metric_type)
312
+
313
+ item = {"metric" : signal.name,
314
+ "score" : f"{signal.score * 100:.1f}%",
315
+ "status" : signal.status.value.upper(),
316
+ "confidence" : f"{metric_result.confidence * 100:.1f}%" if metric_result.confidence else "N/A",
317
+ "explanation" : signal.explanation,
318
+ "key_findings" : self.extract_key_findings(metric_type = signal.metric_type,
319
+ metric_result = metric_result,
320
+ ),
321
+ }
322
+
323
+ breakdown.append(item)
324
+
325
+ return breakdown
326
+
327
+
328
+ def _create_forensic_details(self, analysis_result: AnalysisResult) -> Dict:
329
+ """
330
+ Create forensic details section
331
+ """
332
+ forensic = dict()
333
+
334
+ for metric_type, metric_result in analysis_result.metric_results.items():
335
+ metric_name = self.metric_display_names.get(metric_type, metric_type.value)
336
+ forensic[metric_name] = metric_result.details or {"note": "No detailed forensics available"}
337
+
338
+ return forensic
339
+
340
+
341
+ def _create_recommendations(self, analysis_result: AnalysisResult) -> Dict:
342
+ """
343
+ Create recommendations section
344
+ """
345
+ score = analysis_result.overall_score
346
+
347
+ if (score >= 0.85):
348
+ return {"action" : "Immediate manual verification required",
349
+ "priority" : "HIGH",
350
+ "next_steps" : ["Forensic analysis", "Reverse image search", "Metadata inspection", "Expert review"],
351
+ "confidence" : "Very high likelihood of AI generation",
352
+ }
353
+
354
+ elif (score >= 0.70):
355
+ return {"action" : "Manual verification recommended",
356
+ "priority" : "MEDIUM",
357
+ "next_steps" : ["Visual inspection", "Compare with authentic samples", "Check source provenance"],
358
+ "confidence" : "High likelihood of AI generation",
359
+ }
360
+
361
+ elif (score >= 0.50):
362
+ return {"action" : "Optional review suggested",
363
+ "priority" : "LOW",
364
+ "next_steps" : ["May be edited photo", "Verify image source", "Check for inconsistencies"],
365
+ "confidence" : "Moderate indicators present",
366
+ }
367
+
368
+ else:
369
+ return {"action" : "No immediate action required",
370
+ "priority" : "NONE",
371
+ "next_steps" : ["Proceed with normal workflow"],
372
+ "confidence" : "Low likelihood of AI generation",
373
+ }
374
+
375
+
376
+ def _score_to_status(self, score: float) -> str:
377
+ """
378
+ Convert score to status label
379
+ """
380
+ if (score >= self.signal_thresholds[SignalStatus.FLAGGED]):
381
+ return "FLAGGED"
382
+
383
+ elif (score >= self.signal_thresholds[SignalStatus.WARNING]):
384
+ return "WARNING"
385
+
386
+ else:
387
+ return "PASSED"
388
+
389
+
390
+ def _calculate_avg_confidence(self, analysis_result: AnalysisResult) -> float:
391
+ """
392
+ Calculate average confidence across all metrics
393
+ """
394
+ confidences = [mr.confidence for mr in analysis_result.metric_results.values() if mr.confidence is not None]
395
+
396
+ return round(sum(confidences) / len(confidences), 3) if confidences else 0.0
397
+
398
+
399
+ def _calculate_risk_level(self, score: float) -> str:
400
+ """
401
+ Calculate risk level from score
402
+ """
403
+ if (score >= 0.85):
404
+ return "CRITICAL"
405
+
406
+ elif (score >= 0.70):
407
+ return "HIGH"
408
+
409
+ elif (score >= 0.50):
410
+ return "MEDIUM"
411
+
412
+ else:
413
+ return "LOW"
414
+
415
+
416
+ def extract_key_findings(self, metric_type: MetricType, metric_result) -> List[str]:
417
+ """
418
+ Extract human-readable key forensic findings for a given metric used by:
419
+ - Detailed UI views
420
+ - CSV reports
421
+ - JSON reports
422
+ """
423
+ findings = list()
424
+ details = metric_result.details or {}
425
+
426
+ if (metric_type == MetricType.GRADIENT):
427
+ eig_ratio = details.get('eigenvalue_ratio')
428
+
429
+ if eig_ratio:
430
+ findings.append(f"Eigenvalue ratio: {eig_ratio:.3f}")
431
+
432
+ vectors = details.get('gradient_vectors_sampled')
433
+
434
+ if vectors:
435
+ findings.append(f"Analyzed {vectors} gradient vectors")
436
+
437
+ elif (metric_type == MetricType.FREQUENCY):
438
+ hf_ratio = details.get('hf_ratio')
439
+
440
+ if hf_ratio:
441
+ findings.append(f"High-frequency ratio: {hf_ratio:.3f}")
442
+
443
+ roughness = details.get('roughness')
444
+ if roughness:
445
+ findings.append(f"Spectral roughness: {roughness:.3f}")
446
+
447
+ elif (metric_type == MetricType.NOISE):
448
+ mean_noise = details.get('mean_noise')
449
+
450
+ if mean_noise:
451
+ findings.append(f"Mean noise level: {mean_noise:.2f}")
452
+
453
+ cv = details.get('cv')
454
+
455
+ if cv:
456
+ findings.append(f"Coefficient of variation: {cv:.3f}")
457
+
458
+ elif (metric_type == MetricType.TEXTURE):
459
+ smooth_ratio = details.get('smooth_ratio')
460
+
461
+ if smooth_ratio:
462
+ findings.append(f"Smooth patches: {smooth_ratio:.1%}")
463
+
464
+ contrast_mean = details.get('contrast_mean')
465
+
466
+ if contrast_mean:
467
+ findings.append(f"Average contrast: {contrast_mean:.2f}")
468
+
469
+ elif (metric_type == MetricType.COLOR):
470
+ sat_stats = details.get('saturation_stats', {})
471
+ mean_sat = sat_stats.get('mean_saturation')
472
+
473
+ if mean_sat:
474
+ findings.append(f"Mean saturation: {mean_sat:.2f}")
475
+
476
+ high_sat = sat_stats.get('high_sat_ratio')
477
+
478
+ if high_sat:
479
+ findings.append(f"High saturation pixels: {high_sat:.1%}")
480
+
481
+ return findings if findings else ["Analysis complete"]
features/threshold_manager.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ from typing import Dict
3
+ from utils.logger import get_logger
4
+ from config.settings import settings
5
+ from config.constants import MetricType
6
+ from config.constants import SignalStatus
7
+ from config.constants import SIGNAL_THRESHOLDS
8
+
9
+
10
+ # Setup Logging
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class ThresholdManager:
15
+ """
16
+ Manage detection thresholds dynamically
17
+
18
+ Purpose:
19
+ --------
20
+ Allows runtime adjustment of detection thresholds for:
21
+ - A/B testing different sensitivity levels
22
+ - Calibration based on real-world performance
23
+ - Custom thresholds for specific use cases
24
+ - Environment-specific tuning (production vs staging)
25
+
26
+ Note: Changes are runtime-only and not persisted
27
+ """
28
+ def __init__(self):
29
+ """
30
+ Initialize Threshold Manager with current settings
31
+ """
32
+ self._review_threshold = settings.REVIEW_THRESHOLD
33
+ self._signal_thresholds = dict(SIGNAL_THRESHOLDS)
34
+ self._metric_weights = dict(settings.get_metric_weights())
35
+
36
+ logger.info(f"ThresholdManager initialized: review_threshold={self._review_threshold}")
37
+
38
+
39
+ def get_review_threshold(self) -> float:
40
+ """
41
+ Get current review threshold
42
+
43
+ Returns:
44
+ --------
45
+ { float } : Current threshold [0.0, 1.0]
46
+ """
47
+ return self._review_threshold
48
+
49
+
50
+ def set_review_threshold(self, new_threshold: float) -> bool:
51
+ """
52
+ Set new review threshold
53
+
54
+ Arguments:
55
+ ----------
56
+ new_threshold { float } : New threshold value [0.0, 1.0]
57
+
58
+ Returns:
59
+ --------
60
+ { bool } : Success status
61
+ """
62
+ if not (0.0 <= new_threshold <= 1.0):
63
+ logger.error(f"Invalid threshold: {new_threshold} (must be between 0.0 and 1.0)")
64
+ return False
65
+
66
+ old_threshold = self._review_threshold
67
+ self._review_threshold = new_threshold
68
+
69
+ logger.info(f"Review threshold changed: {old_threshold:.2f} → {new_threshold:.2f}")
70
+
71
+ return True
72
+
73
+
74
+ def adjust_sensitivity(self, sensitivity: str) -> bool:
75
+ """
76
+ Adjust sensitivity using preset levels
77
+
78
+ Arguments:
79
+ ----------
80
+ sensitivity { str } : One of 'conservative', 'balanced', 'aggressive'
81
+
82
+ Returns:
83
+ --------
84
+ { bool } : Success status
85
+ """
86
+ presets = {'conservative' : 0.75, # Fewer false positives, may miss some AI
87
+ 'balanced' : 0.65, # Recommended default
88
+ 'aggressive' : 0.55, # Catch more AI, more false positives
89
+ }
90
+
91
+ if (sensitivity not in presets):
92
+ logger.error(f"Invalid sensitivity: {sensitivity}. Must be one of {list(presets.keys())}")
93
+ return False
94
+
95
+ new_threshold = presets[sensitivity]
96
+ success = self.set_review_threshold(new_threshold = new_threshold)
97
+
98
+ if success:
99
+ logger.info(f"Sensitivity set to '{sensitivity}' (threshold={new_threshold})")
100
+
101
+ return success
102
+
103
+
104
+ def get_signal_thresholds(self) -> Dict[SignalStatus, float]:
105
+ """
106
+ Get current signal thresholds
107
+
108
+ Returns:
109
+ --------
110
+ { dict } : Signal status → threshold mapping
111
+ """
112
+ return self._signal_thresholds.copy()
113
+
114
+
115
+ def set_signal_threshold(self, status: SignalStatus, threshold: float) -> bool:
116
+ """
117
+ Set threshold for specific signal status
118
+
119
+ Arguments:
120
+ ----------
121
+ status { SignalStatus } : Signal status to modify
122
+
123
+ threshold { float } : New threshold [0.0, 1.0]
124
+
125
+ Returns:
126
+ --------
127
+ { bool } : Success status
128
+ """
129
+ if not (0.0 <= threshold <= 1.0):
130
+ logger.error(f"Invalid threshold: {threshold}")
131
+ return False
132
+
133
+ old_threshold = self._signal_thresholds.get(status)
134
+ self._signal_thresholds[status] = threshold
135
+
136
+ logger.info(f"Signal threshold for {status.value}: {old_threshold:.2f} → {threshold:.2f}")
137
+
138
+ return True
139
+
140
+
141
+ def get_metric_weights(self) -> Dict[MetricType, float]:
142
+ """
143
+ Get current metric weights
144
+
145
+ Returns:
146
+ --------
147
+ { dict } : Metric type → weight mapping
148
+ """
149
+ return self._metric_weights.copy()
150
+
151
+
152
+ def set_metric_weight(self, metric: MetricType, weight: float) -> bool:
153
+ """
154
+ Set weight for specific metric
155
+
156
+ Arguments:
157
+ ----------
158
+ metric { MetricType } : Metric to modify
159
+
160
+ weight { float } : New weight [0.0, 1.0]
161
+
162
+ Returns:
163
+ --------
164
+ { bool } : Success status
165
+ """
166
+ if not (0.0 <= weight <= 1.0):
167
+ logger.error(f"Invalid weight: {weight}")
168
+ return False
169
+
170
+ old_weight = self._metric_weights.get(metric, 0.0)
171
+ self._metric_weights[metric] = weight
172
+
173
+ # Validate total weight
174
+ total_weight = sum(self._metric_weights.values())
175
+
176
+ if not (0.99 <= total_weight <= 1.01):
177
+ logger.warning(f"Total metric weights = {total_weight:.3f} (should sum to 1.0)")
178
+
179
+ logger.info(f"Metric weight for {metric.value}: {old_weight:.2f} → {weight:.2f}")
180
+
181
+ return True
182
+
183
+
184
+ def set_all_metric_weights(self, weights: Dict[MetricType, float]) -> bool:
185
+ """
186
+ Set all metric weights at once (ensures sum = 1.0)
187
+
188
+ Arguments:
189
+ ----------
190
+ weights { dict } : Complete metric weights mapping
191
+
192
+ Returns:
193
+ --------
194
+ { bool } : Success status
195
+ """
196
+ # Validate input
197
+ if (not all(0.0 <= w <= 1.0 for w in weights.values())):
198
+ logger.error("All weights must be between 0.0 and 1.0")
199
+ return False
200
+
201
+ total_weight = sum(weights.values())
202
+
203
+ if not (0.99 <= total_weight <= 1.01):
204
+ logger.error(f"Weights must sum to 1.0, got {total_weight:.3f}")
205
+ return False
206
+
207
+ self._metric_weights = dict(weights)
208
+
209
+ logger.info(f"All metric weights updated: {self._metric_weights}")
210
+
211
+ return True
212
+
213
+
214
+ def get_recommendations(self, score: float) -> Dict[str, str]:
215
+ """
216
+ Get action recommendations based on score
217
+
218
+ Arguments:
219
+ ----------
220
+ score { float } : Overall suspicion score [0.0, 1.0]
221
+
222
+ Returns:
223
+ --------
224
+ { dict } : Recommendation details
225
+ """
226
+ if (score >= 0.85):
227
+ return {"priority" : "HIGH",
228
+ "action" : "Immediate manual verification recommended",
229
+ "confidence" : "Very high likelihood of AI generation",
230
+ "next_steps" : "Forensic analysis, reverse image search, metadata inspection",
231
+ }
232
+
233
+ elif (score >= 0.70):
234
+ return {"priority" : "MEDIUM",
235
+ "action" : "Manual verification recommended",
236
+ "confidence" : "High likelihood of AI generation",
237
+ "next_steps" : "Visual inspection, compare with similar authentic images",
238
+ }
239
+
240
+ elif (score >= 0.50):
241
+ return {"priority" : "LOW",
242
+ "action" : "Optional review",
243
+ "confidence" : "Moderate indicators of AI generation",
244
+ "next_steps" : "May be heavily edited real photo, check source",
245
+ }
246
+
247
+ else:
248
+ return {"priority" : "NONE",
249
+ "action" : "No immediate action needed",
250
+ "confidence" : "Low likelihood of AI generation",
251
+ "next_steps" : "Likely authentic, proceed normally",
252
+ }
253
+
254
+
255
+ def get_current_config(self) -> Dict[str, object]:
256
+ """
257
+ Get complete current configuration
258
+
259
+ Returns:
260
+ --------
261
+ { dict } : All current threshold and weight settings
262
+ """
263
+ return {"review_threshold" : self._review_threshold,
264
+ "signal_thresholds" : self._signal_thresholds.copy(),
265
+ "metric_weights" : self._metric_weights.copy(),
266
+ }
267
+
268
+
269
+ def reset_to_defaults(self) -> None:
270
+ """
271
+ Reset all thresholds to default settings
272
+ """
273
+ self._review_threshold = settings.REVIEW_THRESHOLD
274
+ self._signal_thresholds = dict(SIGNAL_THRESHOLDS)
275
+ self._metric_weights = dict(settings.get_metric_weights())
276
+
277
+ logger.info("All thresholds reset to default values")
metrics/__init__.py ADDED
File without changes
metrics/aggregator.py ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import time
3
+ import numpy as np
4
+ from typing import List
5
+ from pathlib import Path
6
+ from types import MappingProxyType
7
+ from utils.logger import get_logger
8
+ from config.settings import settings
9
+ from config.schemas import MetricResult
10
+ from config.constants import MetricType
11
+ from config.constants import SignalStatus
12
+ from config.schemas import AnalysisResult
13
+ from config.schemas import DetectionSignal
14
+ from config.constants import DetectionStatus
15
+ from config.constants import SIGNAL_THRESHOLDS
16
+ from utils.image_processor import ImageProcessor
17
+ from config.constants import METRIC_EXPLANATIONS
18
+ from metrics.noise_analyzer import NoiseAnalyzer
19
+ from metrics.color_analyzer import ColorAnalyzer
20
+ from metrics.texture_analyzer import TextureAnalyzer
21
+ from features.threshold_manager import ThresholdManager
22
+ from config.constants import IMAGE_RESIZE_MAX_DIMENSION
23
+ from metrics.frequency_analyzer import FrequencyAnalyzer
24
+ from metrics.gradient_field_pca import GradientFieldPCADetector
25
+
26
+ # Suppress NumPy warning
27
+ np.seterr(divide = 'ignore',
28
+ invalid = 'ignore',
29
+ )
30
+
31
+
32
+ # Setup Logging
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ class MetricsAggregator:
37
+ """
38
+ Main detector that orchestrates all detection methods
39
+
40
+ Combines multiple unsupervised metrics:
41
+ ----------------------------------------
42
+ 1. Gradient-Field PCA
43
+ 2. Frequency Domain Analysis (FFT)
44
+ 3. Noise Pattern Analysis
45
+ 4. Texture Analysis
46
+ 5. Color Distribution Analysis
47
+
48
+ Note: Each metric produces a suspicion score [0.0, 1.0] : scores are combined using weighted average to produce final assessment
49
+ """
50
+ def __init__(self, threshold_manager: ThresholdManager | None = None):
51
+ """
52
+ Initialize all detectors
53
+ """
54
+ logger.info("Initializing AI Image Detector")
55
+
56
+ # Optional runtime threshold manager
57
+ self.threshold_manager = threshold_manager
58
+
59
+ self.gradient_field_pca_detector = GradientFieldPCADetector()
60
+ self.frequency_analyzer = FrequencyAnalyzer()
61
+ self.noise_analyzer = NoiseAnalyzer()
62
+ self.texture_analyzer = TextureAnalyzer()
63
+ self.color_analyzer = ColorAnalyzer()
64
+ self.image_processor = ImageProcessor()
65
+
66
+ # Create detector registry
67
+ self.detector_registry = MappingProxyType({MetricType.GRADIENT : ("Gradient Field PCA", self.gradient_field_pca_detector),
68
+ MetricType.FREQUENCY : ("Frequency Analysis", self.frequency_analyzer),
69
+ MetricType.NOISE : ("Noise Analysis", self.noise_analyzer),
70
+ MetricType.TEXTURE : ("Texture Analysis", self.texture_analyzer),
71
+ MetricType.COLOR : ("Color Analysis", self.color_analyzer),
72
+ })
73
+
74
+ # Get metric weights either from runtime UI or default to settings
75
+ self.weights = (self.threshold_manager.get_metric_weights() if self.threshold_manager else settings.get_metric_weights())
76
+
77
+ logger.info(f"Metric weights: {self.weights}")
78
+
79
+
80
+ def analyze_image(self, image_path: Path, filename: str, image_size: tuple) -> AnalysisResult:
81
+ """
82
+ Analyze single image for AI generation
83
+
84
+ Arguments:
85
+ ----------
86
+ image_path { Path } : Path to image file
87
+
88
+ filename { str } : Original filename
89
+
90
+ image_size { tuple } : (width, height) tuple
91
+
92
+ Returns:
93
+ --------
94
+ { AnalysisResult } : AnalysisResult with detection outcome
95
+ """
96
+ logger.info(f"Analyzing image: {filename}")
97
+
98
+ start_time = time.time()
99
+
100
+ try:
101
+ # Load image
102
+ image = self.image_processor.load_image(file_path = image_path)
103
+
104
+ # Resize if needed for performance
105
+ image = self.image_processor.resize_if_needed(image = image,
106
+ max_dimension = IMAGE_RESIZE_MAX_DIMENSION,
107
+ )
108
+
109
+ # Run all detectors and get raw scores
110
+ metric_results = self._run_all_detectors(image = image)
111
+
112
+ # Create signals from scores (aggregator's responsibility)
113
+ signals = self._create_signals_from_scores(metric_results = metric_results)
114
+
115
+ # Aggregate results
116
+ overall_score = self._aggregate_scores(metric_results = metric_results)
117
+
118
+ # Determine status
119
+ status = self._determine_status(overall_score = overall_score)
120
+
121
+ # Calculate processing time
122
+ processing_time = time.time() - start_time
123
+
124
+ # Create result
125
+ result = AnalysisResult(filename = filename,
126
+ overall_score = overall_score,
127
+ status = status,
128
+ confidence = int(overall_score * 100),
129
+ signals = signals,
130
+ metric_results = metric_results,
131
+ processing_time = processing_time,
132
+ image_size = image_size,
133
+ )
134
+
135
+ logger.info(f"Analysis complete for {filename}: status={status.value}, score={overall_score:.3f}, time={processing_time:.2f}s")
136
+
137
+ return result
138
+
139
+ except Exception as e:
140
+ logger.error(f"Analysis failed for {filename}: {e}")
141
+ raise
142
+
143
+
144
+ def _run_all_detectors(self, image: np.ndarray) -> dict[MetricType, MetricResult]:
145
+ """
146
+ Run all detection methods and collect raw scores
147
+
148
+ Arguments:
149
+ ----------
150
+ image { np.ndarray } : RGB image array
151
+
152
+ Returns:
153
+ --------
154
+ { dict } : Dictionary mapping MetricType to MetricResult
155
+ """
156
+ metric_results = dict()
157
+
158
+ # Run eaach detector one by one
159
+ for metric_type, (detector_name, detector) in self.detector_registry.items():
160
+ try:
161
+ result = detector.detect(image = image)
162
+ result.metric_type = metric_type
163
+ metric_results[metric_type] = result
164
+
165
+ logger.debug(f"{detector_name} | {metric_type.value} | score={result.score:.3f} | confidence={result.confidence:.3f}")
166
+
167
+ except Exception as e:
168
+ logger.error(f"{detector.__class__.__name__} failed: {e}")
169
+
170
+ # Same Failure Score by all metrics with same confidence
171
+ metric_results[metric_type] = MetricResult(metric_type = metric_type,
172
+ score = settings.REVIEW_THRESHOLD,
173
+ confidence = 0.0,
174
+ details = {"error": "detector_failed"},
175
+ )
176
+
177
+ return metric_results
178
+
179
+
180
+ def _create_signals_from_scores(self, metric_results: dict) -> List[DetectionSignal]:
181
+ """
182
+ Convert MetricResults to DetectionSignals with status and explanations
183
+
184
+ This is the aggregator's responsibility - metrics don't know about signals
185
+
186
+ Arguments:
187
+ ----------
188
+ metric_results { dict } : Dictionary mapping MetricType to float score
189
+
190
+ Returns:
191
+ --------
192
+ { list } : List of complete detection signals
193
+ """
194
+ signals = list()
195
+
196
+ signal_thresholds = (self.threshold_manager.get_signal_thresholds() if self.threshold_manager else SIGNAL_THRESHOLDS)
197
+
198
+ for metric_type, result in metric_results.items():
199
+ # Extract score of the metric
200
+ score = result.score
201
+
202
+ # Determine status based on thresholds
203
+ if (score >= signal_thresholds[SignalStatus.FLAGGED]):
204
+ status = SignalStatus.FLAGGED
205
+ severity = 'high'
206
+
207
+ elif (score >= signal_thresholds[SignalStatus.WARNING]):
208
+ status = SignalStatus.WARNING
209
+ severity = 'moderate'
210
+
211
+ else:
212
+ status = SignalStatus.PASSED
213
+ severity = 'normal'
214
+
215
+ # Get explanation from constants
216
+ explanation = METRIC_EXPLANATIONS[metric_type][severity]
217
+
218
+ # Create signal
219
+ signal = DetectionSignal(name = self.detector_registry[metric_type][0],
220
+ metric_type = metric_type,
221
+ score = score,
222
+ status = status,
223
+ explanation = explanation,
224
+ )
225
+
226
+ signals.append(signal)
227
+
228
+ # Sort signals by score (highest first)
229
+ signals.sort(key = lambda s: s.score, reverse = True)
230
+
231
+ return signals
232
+
233
+
234
+ def _aggregate_scores(self, metric_results: dict) -> float:
235
+ """
236
+ Aggregate individual metric scores using weighted average
237
+
238
+ Arguments:
239
+ ----------
240
+ metric_results { dict } : Dictionary mapping MetricType to float score
241
+
242
+ Returns:
243
+ --------
244
+ { float } : Overall suspicion score [0.0, 1.0]
245
+ """
246
+ total_score = 0.0
247
+ total_weight = 0.0
248
+
249
+ for metric_type, result in metric_results.items():
250
+ weight = self.weights.get(metric_type, 0.0)
251
+ total_score += result.score * weight
252
+ total_weight += weight
253
+
254
+
255
+ # Get Aggregated Score
256
+ if (total_weight > 0):
257
+ # Normalize
258
+ overall_score = total_score / total_weight
259
+
260
+ else:
261
+ # Neutral if no valid weights
262
+ overall_score = 0.5
263
+
264
+ logger.debug(f"Aggregated score: {overall_score:.3f}")
265
+
266
+ return float(np.clip(overall_score, 0.0, 1.0))
267
+
268
+
269
+ def _determine_status(self, overall_score: float) -> DetectionStatus:
270
+ """
271
+ Determine binary status from overall score
272
+
273
+ Arguments:
274
+ ----------
275
+ overall_score { float } : Aggregated suspicion score
276
+
277
+ Returns:
278
+ --------
279
+ { DetectionStatus } : LIKELY_AUTHENTIC or REVIEW_REQUIRED
280
+ """
281
+ # Extract review threshold either from threshold_manager or deault to settings value
282
+ review_threshold = (self.threshold_manager.get_review_threshold() if self.threshold_manager else settings.REVIEW_THRESHOLD)
283
+
284
+ if (overall_score >= review_threshold):
285
+ return DetectionStatus.REVIEW_REQUIRED
286
+
287
+ else:
288
+ return DetectionStatus.LIKELY_AUTHENTIC
metrics/color_analyzer.py ADDED
@@ -0,0 +1,352 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import numpy as np
3
+ from utils.logger import get_logger
4
+ from config.schemas import MetricResult
5
+ from config.constants import MetricType
6
+ from utils.image_processor import ImageProcessor
7
+ from config.constants import COLOR_ANALYSIS_PARAMS
8
+
9
+ # Suppress NumPy warning
10
+ np.seterr(divide = 'ignore',
11
+ invalid = 'ignore',
12
+ )
13
+
14
+
15
+ # Setup Logging
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class ColorAnalyzer:
20
+ """
21
+ Color distribution analysis for AI detection
22
+
23
+ Core principle:
24
+ ---------------
25
+ - Real photos : Natural color distributions constrained by physics
26
+ - AI images : Can create unnatural saturation, hue shifts, or impossible color relationships
27
+
28
+ Method:
29
+ -------
30
+ 1. Convert to multiple color spaces (RGB, HSV)
31
+ 2. Analyze color histogram distributions
32
+ 3. Check for oversaturation
33
+ 4. Detect unnatural color relationships
34
+ """
35
+ def __init__(self):
36
+ self.image_processor = ImageProcessor()
37
+
38
+
39
+ def detect(self, image: np.ndarray) -> MetricResult:
40
+ """
41
+ Run color distribution analysis
42
+
43
+ Arguments:
44
+ ----------
45
+ image { np.ndarray } : RGB image array (H, W, 3)
46
+
47
+ Returns:
48
+ --------
49
+ { MetricResult } : Structured Color-domain metric result containing:
50
+ - score : Suspicion score [0.0, 1.0]
51
+ - confidence : Reliability of color analysis evidence
52
+ - details : Color Analysis forensics and statistics
53
+ """
54
+ try:
55
+ logger.debug(f"Running color analysis on image shape {image.shape}")
56
+
57
+ # Normalize image to [0, 1]
58
+ image_norm = self.image_processor.normalize_image(image = image)
59
+
60
+ # Convert to HSV
61
+ hsv = self._rgb_to_hsv(rgb = image_norm)
62
+
63
+ # Analyze saturation
64
+ saturation_score, saturation_details = self._analyze_saturation(hsv = hsv)
65
+
66
+ # Analyze color histogram
67
+ histogram_score, histogram_details = self._analyze_color_histogram(rgb = image_norm)
68
+
69
+ # Analyze hue distribution
70
+ hue_score, hue_details = self._analyze_hue_distribution(hsv = hsv)
71
+
72
+ # Combine scores
73
+ weights = COLOR_ANALYSIS_PARAMS.MAIN_WEIGHTS
74
+ final_score = (weights['saturation'] * saturation_score + weights['histogram'] * histogram_score + weights['hue'] * hue_score)
75
+
76
+ # Calculate Confidence
77
+ confidence = float(np.clip((abs(final_score - COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE) * 2.0), 0.0, 1.0))
78
+
79
+ logger.debug(f"Color analysis: saturation={saturation_score:.3f}, histogram={histogram_score:.3f}, hue={hue_score:.3f}, Score={final_score:.3f}")
80
+
81
+ return MetricResult(metric_type = MetricType.COLOR,
82
+ score = float(final_score),
83
+ confidence = confidence,
84
+ details = {"saturation_stats" : saturation_details,
85
+ "histogram_stats" : histogram_details,
86
+ "hue_stats" : hue_details,
87
+ },
88
+ )
89
+
90
+ except Exception as e:
91
+ logger.error(f"Color analysis failed: {e}")
92
+
93
+ # Return neutral score on error
94
+ return MetricResult(metric_type = MetricType.COLOR,
95
+ score = COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE,
96
+ confidence = 0.0,
97
+ details = {"error": "color_analysis_failed"},
98
+ )
99
+
100
+
101
+ def _rgb_to_hsv(self, rgb: np.ndarray) -> np.ndarray:
102
+ """
103
+ Convert RGB to HSV color space
104
+
105
+ Arguments:
106
+ ----------
107
+ rgb { np.ndarray } : RGB image normalized to [0, 1]
108
+
109
+ Returns:
110
+ --------
111
+ { np.ndarray } : HSV image (H in [0, 360], S and V in [0, 1])
112
+ """
113
+ r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2]
114
+
115
+ maxc = np.maximum(np.maximum(r, g), b)
116
+ minc = np.minimum(np.minimum(r, g), b)
117
+ delta = maxc - minc
118
+
119
+ # Value
120
+ v = maxc
121
+
122
+ # Saturation
123
+ s = np.where(maxc != 0, delta / maxc, 0)
124
+
125
+ # Hue
126
+ h = np.zeros_like(maxc)
127
+
128
+ # Red is max
129
+ mask = (maxc == r) & (delta != 0)
130
+ h[mask] = 60 * (((g[mask] - b[mask]) / delta[mask]) % 6)
131
+
132
+ # Green is max
133
+ mask = (maxc == g) & (delta != 0)
134
+ h[mask] = 60 * (((b[mask] - r[mask]) / delta[mask]) + 2)
135
+
136
+ # Blue is max
137
+ mask = (maxc == b) & (delta != 0)
138
+ h[mask] = 60 * (((r[mask] - g[mask]) / delta[mask]) + 4)
139
+
140
+ hsv = np.stack([h, s, v], axis = 2)
141
+
142
+ return hsv
143
+
144
+
145
+ def _analyze_saturation(self, hsv: np.ndarray) -> tuple[float, dict]:
146
+ """
147
+ Analyze saturation distribution for anomalies
148
+
149
+ Real photos: Most pixels have moderate saturation (0.2-0.7)
150
+ AI images: Can have too many highly saturated pixels (>0.8)
151
+
152
+ Arguments:
153
+ ----------
154
+ hsv { np.ndarray } : HSV image
155
+
156
+ Returns:
157
+ --------
158
+ { tuple } : A tuple containing:
159
+ - Suspicion score [0.0, 1.0]
160
+ - Saturation Stats
161
+ """
162
+ saturation = hsv[:, :, 1]
163
+
164
+ if (np.mean(saturation) < 0.05):
165
+ logger.debug("Low global saturation; skipping saturation analysis")
166
+ return COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE, {"reason": "insufficient_color_information"}
167
+
168
+ # Compute saturation statistics
169
+ mean_sat = np.mean(saturation)
170
+ high_sat_ratio = np.mean(saturation > COLOR_ANALYSIS_PARAMS.SAT_HIGH_THRESHOLD)
171
+ very_high_sat_ratio = np.mean(saturation > COLOR_ANALYSIS_PARAMS.SAT_VERY_HIGH_THRESHOLD)
172
+
173
+ # Overall saturation level Analysis
174
+ mean_anomaly = 0.0
175
+
176
+ if (mean_sat > COLOR_ANALYSIS_PARAMS.SAT_MEAN_THRESHOLD):
177
+ mean_anomaly = min(1.0, (mean_sat - COLOR_ANALYSIS_PARAMS.SAT_MEAN_THRESHOLD) * COLOR_ANALYSIS_PARAMS.SAT_MEAN_SCALE)
178
+
179
+ # High saturation pixels Analysis
180
+ high_sat_anomaly = 0.0
181
+
182
+ if (high_sat_ratio > COLOR_ANALYSIS_PARAMS.HIGH_SAT_RATIO_THRESHOLD):
183
+ high_sat_anomaly = min(1.0, (high_sat_ratio - COLOR_ANALYSIS_PARAMS.HIGH_SAT_RATIO_THRESHOLD) * COLOR_ANALYSIS_PARAMS.HIGH_SAT_SCALE)
184
+
185
+ # Very high saturation Analysis (clipping)
186
+ clip_anomaly = 0.0
187
+
188
+ if (very_high_sat_ratio > COLOR_ANALYSIS_PARAMS.CLIP_RATIO_THRESHOLD):
189
+ clip_anomaly = min(1.0, (very_high_sat_ratio - COLOR_ANALYSIS_PARAMS.CLIP_RATIO_THRESHOLD) * COLOR_ANALYSIS_PARAMS.CLIP_SCALE)
190
+
191
+ # Combine Scores
192
+ weights = COLOR_ANALYSIS_PARAMS.SAT_SUBMETRIC_WEIGHTS
193
+
194
+ color_score = (weights['mean_anomaly'] * mean_anomaly + weights['high_sat_anomaly'] * high_sat_anomaly + weights['clip_anomaly'] * clip_anomaly)
195
+
196
+ final_score = float(np.clip(color_score, 0.0, 1.0))
197
+
198
+ saturation_stats = {"mean_saturation" : float(mean_sat),
199
+ "high_sat_ratio" : float(high_sat_ratio),
200
+ "very_high_sat_ratio" : float(very_high_sat_ratio),
201
+ "mean_anomaly" : float(mean_anomaly),
202
+ "high_sat_anomaly" : float(high_sat_anomaly),
203
+ "clip_anomaly" : float(clip_anomaly),
204
+ }
205
+
206
+ logger.debug(f"Saturation - mean: {mean_sat:.3f}, high_ratio: {high_sat_ratio:.3f}, clip_ratio: {very_high_sat_ratio:.3f}")
207
+
208
+ return final_score, saturation_stats
209
+
210
+
211
+ def _analyze_color_histogram(self, rgb: np.ndarray) -> tuple[float, dict]:
212
+ """
213
+ Analyze RGB histogram distributions for anomalies
214
+
215
+ Arguments:
216
+ ----------
217
+ rgb { np.ndarray } : RGB image normalized to [0, 1]
218
+
219
+ Returns:
220
+ --------
221
+ { tuple } : A tuple containing:
222
+ - Suspicion score [0.0, 1.0]
223
+ - Histogram Analysis stats
224
+ """
225
+ anomalies = list()
226
+ roughness_vals = list()
227
+ low_clip_vals = list()
228
+ high_clip_vals = list()
229
+
230
+ for channel_idx, channel_name in enumerate(['R', 'G', 'B']):
231
+ channel = rgb[:, :, channel_idx]
232
+
233
+ # Compute histogram
234
+ hist, bins = np.histogram(channel,
235
+ bins = COLOR_ANALYSIS_PARAMS.HISTOGRAM_BINS,
236
+ range = COLOR_ANALYSIS_PARAMS.HISTOGRAM_RANGE,
237
+ )
238
+
239
+ hist = hist / (np.sum(hist) + 1e-10)
240
+
241
+ # Measure histogram roughness
242
+ hist_diff = np.abs(np.diff(hist))
243
+ roughness = np.mean(hist_diff)
244
+ roughness_vals.append(roughness)
245
+
246
+ # High roughness = suspicious
247
+ if (roughness > COLOR_ANALYSIS_PARAMS.ROUGHNESS_THRESHOLD):
248
+ anomalies.append(np.clip(((roughness - COLOR_ANALYSIS_PARAMS.ROUGHNESS_THRESHOLD) * COLOR_ANALYSIS_PARAMS.ROUGHNESS_SCALE), 0.0, 1.0))
249
+
250
+ # Check for clipping (peaks at extremes)
251
+ low_clip = hist[0] + hist[1]
252
+ high_clip = hist[-1] + hist[-2]
253
+
254
+ # Append values to their respective storages
255
+ low_clip_vals.append(low_clip)
256
+ high_clip_vals.append(high_clip)
257
+
258
+ if (low_clip > COLOR_ANALYSIS_PARAMS.CLIP_THRESHOLD):
259
+ # More than 10% near black
260
+ anomalies.append(min(1.0, (low_clip - COLOR_ANALYSIS_PARAMS.CLIP_THRESHOLD) * COLOR_ANALYSIS_PARAMS.CLIP_SCALE_FACTOR))
261
+
262
+ if (high_clip > COLOR_ANALYSIS_PARAMS.CLIP_THRESHOLD):
263
+ # More than 10% near white
264
+ anomalies.append(min(1.0, (high_clip - COLOR_ANALYSIS_PARAMS.CLIP_THRESHOLD) * COLOR_ANALYSIS_PARAMS.CLIP_SCALE_FACTOR))
265
+
266
+ if (len(anomalies) == 0):
267
+ logger.debug("No color histogram anomalies detected")
268
+ return COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE, {"reason": "insufficient_color_information"}
269
+
270
+ # Take mean of detected anomalies
271
+ score = np.mean(anomalies)
272
+ final_score = float(np.clip(score, 0.0, 1.0))
273
+
274
+ histogram_stats = {"roughness_mean" : float(np.mean(roughness_vals)),
275
+ "low_clip_mean" : float(np.mean(low_clip_vals)),
276
+ "high_clip_mean" : float(np.mean(high_clip_vals)),
277
+ "channels_analyzed" : 3,
278
+ }
279
+
280
+ return final_score, histogram_stats
281
+
282
+
283
+ def _analyze_hue_distribution(self, hsv: np.ndarray) -> tuple[float, dict]:
284
+ """
285
+ Analyze hue distribution for unnatural patterns
286
+
287
+ Arguments:
288
+ ----------
289
+ hsv { np.ndarray } : HSV image
290
+
291
+ Returns:
292
+ --------
293
+ { tuple } : A tuple containing:
294
+ - Suspicion score [0.0, 1.0]
295
+ - hue analysis stats
296
+ """
297
+ hue = hsv[:, :, 0]
298
+ saturation = hsv[:, :, 1]
299
+
300
+ # Only consider pixels with sufficient saturation (avoid gray)
301
+ saturated_mask = saturation > COLOR_ANALYSIS_PARAMS.HUE_SAT_MASK_THRESHOLD
302
+
303
+ if (np.sum(saturated_mask) < COLOR_ANALYSIS_PARAMS.HUE_MIN_PIXELS):
304
+ # Not enough colored pixels to analyze
305
+ return COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE, {"reason": "insufficient_color_information"}
306
+
307
+ hue_saturated = hue[saturated_mask]
308
+
309
+ # Prevents false positives on monotone objects
310
+ if (np.ptp(hue_saturated) < 5.0):
311
+ logger.debug("Hue range too narrow; returning neutral score")
312
+ return COLOR_ANALYSIS_PARAMS.NEUTRAL_SCORE, {"reason": "insufficient_color_information"}
313
+
314
+ # Compute hue histogram
315
+ hist, bins = np.histogram(a = hue_saturated,
316
+ bins = COLOR_ANALYSIS_PARAMS.HUE_BINS,
317
+ range = COLOR_ANALYSIS_PARAMS.HUE_RANGE,
318
+ )
319
+
320
+ hist = hist / (np.sum(hist) + 1e-10)
321
+
322
+ # Unnatural hue concentration Analysis
323
+ sorted_hist = np.sort(hist)[::-1]
324
+ top3_concentration = np.sum(sorted_hist[:3])
325
+ concentration_anomaly = 0.0
326
+
327
+ if (top3_concentration > COLOR_ANALYSIS_PARAMS.HUE_CONCENTRATION_THRESHOLD):
328
+ # More than 60% in 3 hue bins
329
+ concentration_anomaly = min(1.0, (top3_concentration - COLOR_ANALYSIS_PARAMS.HUE_CONCENTRATION_THRESHOLD) * COLOR_ANALYSIS_PARAMS.HUE_CONCENTRATION_SCALE)
330
+
331
+ # Hue gaps Analysis
332
+ zero_bins = np.sum(hist < COLOR_ANALYSIS_PARAMS.HUE_EMPTY_BIN_THRESHOLD)
333
+ gap_ratio = zero_bins / len(hist)
334
+ gap_anomaly = 0.0
335
+
336
+ if (gap_ratio > COLOR_ANALYSIS_PARAMS.HUE_GAP_RATIO_THRESHOLD):
337
+ # More than 40% empty bins
338
+ gap_anomaly = min(1.0, (gap_ratio - COLOR_ANALYSIS_PARAMS.HUE_GAP_RATIO_THRESHOLD) * COLOR_ANALYSIS_PARAMS.HUE_GAP_SCALE)
339
+
340
+ weights = COLOR_ANALYSIS_PARAMS.HUE_SUBMETRIC_WEIGHTS
341
+ score = (weights['concentration_anomaly'] * concentration_anomaly + weights['gap_anomaly'] * gap_anomaly)
342
+ final_score = float(np.clip(score, 0.0, 1.0))
343
+
344
+ hue_stats = {"top3_concentration" : float(top3_concentration),
345
+ "gap_ratio" : float(gap_ratio),
346
+ "concentration_anomaly" : float(concentration_anomaly),
347
+ "gap_anomaly" : float(gap_anomaly),
348
+ }
349
+
350
+ logger.debug(f"Hue - concentration: {top3_concentration:.3f}, gap_ratio: {gap_ratio:.3f}")
351
+
352
+ return final_score, hue_stats
metrics/frequency_analyzer.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import numpy as np
3
+ from scipy import fft
4
+ from utils.logger import get_logger
5
+ from config.schemas import MetricResult
6
+ from config.constants import MetricType
7
+ from utils.image_processor import ImageProcessor
8
+ from config.constants import FREQUENCY_ANALYSIS_PARAMS
9
+
10
+ # Suppress NumPy warning
11
+ np.seterr(divide = 'ignore',
12
+ invalid = 'ignore',
13
+ )
14
+
15
+
16
+ # Setup Logging
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class FrequencyAnalyzer:
21
+ """
22
+ FFT-based frequency domain analysis for AI detection
23
+
24
+ Core principle:
25
+ ---------------
26
+ - Real photos : Smooth frequency falloff (natural optical blur)
27
+ - AI images : Unnatural frequency spikes or gaps (artifacts from generation)
28
+
29
+ Method:
30
+ -------
31
+ 1. Convert to luminance
32
+ 2. Compute 2D FFT
33
+ 3. Compute radial frequency spectrum
34
+ 4. Analyze high-frequency content and distribution patterns
35
+ """
36
+ def __init__(self):
37
+ self.image_processor = ImageProcessor()
38
+
39
+
40
+ def detect(self, image: np.ndarray) -> MetricResult:
41
+ """
42
+ Run frequency domain analysis
43
+
44
+ Arguments:
45
+ ----------
46
+ image { np.ndarray } : RGB image array (H, W, 3)
47
+
48
+ Returns:
49
+ --------
50
+ { MetricResult } : Structured frequency-domain metric result containing:
51
+ - score : Suspicion score [0.0, 1.0]
52
+ - confidence : Reliability of frequency evidence
53
+ - details : FFT and spectrum diagnostics
54
+ """
55
+ try:
56
+ logger.debug(f"Running frequency analysis on image shape {image.shape}")
57
+
58
+ # Convert to luminance
59
+ luminance = self.image_processor.rgb_to_luminance(image = image)
60
+
61
+ # Normalize luminance (remove DC component for FFT stability)
62
+ normalized_luminance = luminance - np.mean(luminance)
63
+
64
+ if not np.any(normalized_luminance):
65
+ logger.debug("FFT skipped: zero-variance luminance")
66
+
67
+ return MetricResult(metric_type = MetricType.FREQUENCY,
68
+ score = FREQUENCY_ANALYSIS_PARAMS.NEUTRAL_SCORE,
69
+ confidence = 0.0,
70
+ details = {"reason": "zero_variance_luminance"}
71
+ )
72
+
73
+ # Compute FFT on normalized_luminance
74
+ fft_magnitude = self._compute_fft_magnitude(luminance = normalized_luminance)
75
+
76
+ # Analyze radial frequency spectrum
77
+ radial_spectrum = self._compute_radial_spectrum(fft_magnitude = fft_magnitude)
78
+
79
+ # Detect anomalies
80
+ anomaly_score, freq_details = self._analyze_frequency_anomalies(radial_spectrum = radial_spectrum)
81
+
82
+ logger.debug(f"Frequency analysis: Anomaly Score={anomaly_score:.3f}")
83
+
84
+ # Distance from neutral = stronger evidence = higher confidence
85
+ confidence = float(np.clip((abs(anomaly_score - FREQUENCY_ANALYSIS_PARAMS.NEUTRAL_SCORE) * 2.0), 0.0, 1.0))
86
+
87
+ return MetricResult(metric_type = MetricType.FREQUENCY,
88
+ score = float(anomaly_score),
89
+ confidence = confidence,
90
+ details = {"spectrum_bins" : int(len(radial_spectrum)),
91
+ **freq_details,
92
+ }
93
+ )
94
+
95
+ except Exception as e:
96
+ logger.error(f"Frequency analysis failed: {e}")
97
+
98
+ # Return neutral score on error
99
+ return MetricResult(metric_type = MetricType.FREQUENCY,
100
+ score = FREQUENCY_ANALYSIS_PARAMS.NEUTRAL_SCORE,
101
+ confidence = 0.0,
102
+ details = {"error" : "frequency_analysis_failed"},
103
+ )
104
+
105
+
106
+ def _compute_fft_magnitude(self, luminance: np.ndarray) -> np.ndarray:
107
+ """
108
+ Compute 2D FFT magnitude spectrum
109
+
110
+ Arguments:
111
+ ----------
112
+ luminance { np.ndarray } : Luminance channel (H, W)
113
+
114
+ Returns:
115
+ --------
116
+ { np.ndarray } : FFT magnitude spectrum (centered)
117
+ """
118
+ # Compute 2D FFT
119
+ f = fft.fft2(luminance)
120
+
121
+ # Shift zero frequency to center
122
+ f_shifted = fft.fftshift(f)
123
+
124
+ # Compute magnitude spectrum
125
+ magnitude = np.abs(f_shifted)
126
+
127
+ # Log scale for better visualization
128
+ magnitude_log = np.log1p(magnitude)
129
+
130
+ return magnitude_log
131
+
132
+
133
+ def _compute_radial_spectrum(self, fft_magnitude: np.ndarray) -> np.ndarray:
134
+ """
135
+ Compute radial average of frequency spectrum
136
+
137
+ Arguments:
138
+ ----------
139
+ fft_magnitude { np.ndarray } : FFT magnitude spectrum
140
+
141
+ Returns:
142
+ --------
143
+ { np.ndarray } : Radial spectrum (1D array)
144
+ """
145
+ h, w = fft_magnitude.shape
146
+ center_y, center_x = h // 2, w // 2
147
+
148
+ # Create coordinate grids
149
+ y, x = np.ogrid[:h, :w]
150
+
151
+ # Compute radial distances from center
152
+ r = np.sqrt((x - center_x)**2 + (y - center_y)**2).astype(int)
153
+
154
+ # Maximum radius
155
+ max_radius = min(center_x, center_y)
156
+
157
+ # Compute radial bins
158
+ bins = np.linspace(0, max_radius, FREQUENCY_ANALYSIS_PARAMS.BINS + 1)
159
+ radial_spectrum = np.zeros(FREQUENCY_ANALYSIS_PARAMS.BINS)
160
+
161
+ # Average magnitude in each radial bin
162
+ for i in range(FREQUENCY_ANALYSIS_PARAMS.BINS):
163
+ mask = (r >= bins[i]) & (r < bins[i + 1])
164
+
165
+ if np.any(mask):
166
+ radial_spectrum[i] = np.mean(fft_magnitude[mask])
167
+
168
+ return radial_spectrum
169
+
170
+
171
+ def _analyze_frequency_anomalies(self, radial_spectrum: np.ndarray) -> tuple[float, dict]:
172
+ """
173
+ Analyze frequency spectrum for AI generation artifacts
174
+
175
+ Checks:
176
+ -------
177
+ 1. High-frequency content (AI images often have unnatural HF energy)
178
+ 2. Frequency distribution smoothness
179
+ 3. Spectral slope deviation from natural images
180
+
181
+ Arguments:
182
+ ----------
183
+ radial_spectrum { np.ndarray } : Radial frequency spectrum
184
+
185
+ Returns:
186
+ --------
187
+ { tuple } : A tuple containing
188
+ - Suspicion score [0.0, 1.0], and
189
+ - frequency details in a dictionary
190
+ """
191
+ if (len(radial_spectrum) < FREQUENCY_ANALYSIS_PARAMS.MIN_SPECTRUM_SAMPLES):
192
+ return (FREQUENCY_ANALYSIS_PARAMS.NEUTRAL_SCORE,
193
+ {"reason" : "insufficient_frequency_samples",
194
+ "spectrum_bins" : int(len(radial_spectrum)),
195
+ }
196
+ )
197
+
198
+ # Normalize spectrum
199
+ spectrum_norm = radial_spectrum / (np.max(radial_spectrum) + 1e-10)
200
+
201
+ # High-frequency Energy Analysis
202
+ high_freq_start = int(len(spectrum_norm) * FREQUENCY_ANALYSIS_PARAMS.HIGH_FREQ_THRESHOLD)
203
+
204
+ if (high_freq_start >= len(spectrum_norm) - 1):
205
+ return (FREQUENCY_ANALYSIS_PARAMS.NEUTRAL_SCORE,
206
+ {"reason" : "invalid_frequency_partition"}
207
+ )
208
+
209
+ high_freq_energy = np.mean(spectrum_norm[high_freq_start:])
210
+ low_freq_energy = np.mean(spectrum_norm[:high_freq_start])
211
+
212
+ hf_ratio = high_freq_energy / (low_freq_energy + 1e-10)
213
+
214
+ # Natural images : HF ratio typically 0.1-0.3
215
+ # AI images : Can be higher (0.3-0.6) or lower (<0.1)
216
+ hf_anomaly = 0.0
217
+
218
+ if (hf_ratio > FREQUENCY_ANALYSIS_PARAMS.HF_RATIO_UPPER):
219
+ hf_anomaly = min(1.0, (hf_ratio - FREQUENCY_ANALYSIS_PARAMS.HF_RATIO_UPPER) * FREQUENCY_ANALYSIS_PARAMS.HF_UPPER_SCALE)
220
+
221
+ elif (hf_ratio < FREQUENCY_ANALYSIS_PARAMS.HF_RATIO_LOWER):
222
+ hf_anomaly = min(1.0, (FREQUENCY_ANALYSIS_PARAMS.HF_RATIO_LOWER - hf_ratio) * FREQUENCY_ANALYSIS_PARAMS.HF_LOWER_SCALE)
223
+
224
+ # Spectral Smoothness Analysis
225
+ spectral_diff = np.abs(np.diff(spectrum_norm))
226
+ roughness = np.mean(spectral_diff)
227
+ roughness_score = np.clip(roughness * FREQUENCY_ANALYSIS_PARAMS.ROUGHNESS_SCALE, 0.0, 1.0)
228
+
229
+ # Power Law Deviation Analysis
230
+ x = np.arange(1, len(spectrum_norm) + 1)
231
+ log_spectrum = np.log(spectrum_norm + 1e-10)
232
+ log_x = np.log(x)
233
+
234
+ # Linear fit in log-log space
235
+ coeffs = np.polyfit(log_x, log_spectrum, 1)
236
+ fitted = np.polyval(coeffs, log_x)
237
+ deviation = np.mean(np.abs(log_spectrum - fitted))
238
+ deviation_score = np.clip(deviation * FREQUENCY_ANALYSIS_PARAMS.DEVIATION_SCALE, 0.0, 1.0)
239
+
240
+ # Combine scores
241
+ weights = FREQUENCY_ANALYSIS_PARAMS.SUBMETRIC_WEIGHTS
242
+
243
+ combined_score = (weights['hf_anomaly'] * hf_anomaly + weights['roughness'] * roughness_score + weights['deviation'] * deviation_score)
244
+
245
+ final_score = float(np.clip(combined_score, 0.0, 1.0))
246
+
247
+ frequency_dict = {"low_freq_energy" : float(low_freq_energy),
248
+ "high_freq_energy" : float(high_freq_energy),
249
+ "hf_ratio" : float(hf_ratio),
250
+ "hf_anomaly" : float(hf_anomaly),
251
+ "roughness" : float(roughness),
252
+ "roughness_score" : float(roughness_score),
253
+ "spectral_deviation" : float(deviation),
254
+ "deviation_score" : float(deviation_score),
255
+ "high_freq_start_bin" : int(high_freq_start),
256
+ }
257
+
258
+ logger.debug(f"FFT scores - HF anomaly: {hf_anomaly:.3f}, roughness: {roughness_score:.3f}, deviation: {deviation_score:.3f}")
259
+
260
+ return (final_score, frequency_dict)
metrics/gradient_field_pca.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import numpy as np
3
+ from utils.logger import get_logger
4
+ from config.schemas import MetricResult
5
+ from config.constants import MetricType
6
+ from utils.image_processor import ImageProcessor
7
+ from config.constants import GRADIENT_FIELD_PCA_PARAMS
8
+
9
+ # Suppress NumPy warning
10
+ np.seterr(divide = 'ignore',
11
+ invalid = 'ignore',
12
+ )
13
+
14
+
15
+ # Setup Logging
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class GradientFieldPCADetector:
20
+ """
21
+ Detects AI-generated images by analyzing gradient field consistency. Real photos have consistent gradient
22
+ patterns shaped by physics (lighting, optics). Diffusion models struggle to maintain physically consistent
23
+ gradients due to denoising
24
+
25
+ Core principle:
26
+ ---------------
27
+ - Real photos : Gradients align with physical light sources (low-dimensional structure)
28
+ - AI images : Gradients are inconsistent due to patch-based denoising (high-dimensional)
29
+
30
+ Method:
31
+ -------
32
+ 1. Convert to luminance
33
+ 2. Compute Sobel gradients (Gx, Gy)
34
+ 3. Flatten to gradient vectors per pixel
35
+ 4. Compute covariance matrix
36
+ 5. PCA eigenvalue analysis
37
+ """
38
+ def __init__(self):
39
+ """
40
+ Initialize Gradient-Field PCA Detector class
41
+ """
42
+ self._range = np.random.default_rng(seed = GRADIENT_FIELD_PCA_PARAMS.RANDOM_SEED)
43
+ self.image_processor = ImageProcessor()
44
+
45
+
46
+ def detect(self, image: np.ndarray) -> MetricResult:
47
+ """
48
+ Run gradient PCA detection
49
+
50
+ Arguments:
51
+ ----------
52
+ image { np.ndarray } : RGB image array (H, W, 3)
53
+
54
+ Returns:
55
+ --------
56
+ { MetricResult } : Structured metric result containing:
57
+ - score : Suspicion score [0.0, 1.0] (0 = natural, 1 = suspicious)
58
+ - confidence : Confidence of this metric's assessment [0.0, 1.0]
59
+ - details : Explainability metadata for UI and reports
60
+ """
61
+ try:
62
+ logger.debug(f"Running gradient PCA detection on image shape {image.shape}")
63
+
64
+ # Convert image to luminance
65
+ luminance = self.image_processor.rgb_to_luminance(image = image)
66
+
67
+ # Compute gradients
68
+ gx, gy = self.image_processor.compute_gradients(luminance = luminance)
69
+
70
+ # Flatten and sample gradient vectors
71
+ gradient_vectors = self._prepare_and_sample_gradients(gx = gx,
72
+ gy = gy,
73
+ )
74
+
75
+ # Perform PCA
76
+ eigenvalue_ratio = self._compute_eigenvalue_ratio(gradient_vectors = gradient_vectors)
77
+
78
+ if ((len(gradient_vectors) < GRADIENT_FIELD_PCA_PARAMS.MIN_SAMPLES) or (eigenvalue_ratio == GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE)):
79
+ return MetricResult(metric_type = MetricType.GRADIENT,
80
+ score = GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE,
81
+ confidence = 0.0,
82
+ details = {"reason" : "insufficient_gradient_information",
83
+ "original_pixels" : int(gx.size),
84
+ "filtered_vectors" : int(len(gradient_vectors)),
85
+ },
86
+ )
87
+
88
+ # Convert to suspicion score
89
+ suspicion_score = self._eigenvalue_to_suspicion(eigenvalue_ratio = eigenvalue_ratio)
90
+
91
+ # Confidence inverted relative to suspicion: High eigenvalue_ratio = natural, High suspicion_score = AI-like
92
+ confidence = abs(eigenvalue_ratio - GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD)
93
+ normalized_confidence = np.clip((confidence / GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD), 0.0, 1.0)
94
+
95
+ logger.debug(f"Gradient PCA: eigenvalue_ratio={eigenvalue_ratio:.3f}, suspicion_score={suspicion_score:.3f}")
96
+
97
+ return MetricResult(metric_type = MetricType.GRADIENT,
98
+ score = float(suspicion_score),
99
+ confidence = float(normalized_confidence),
100
+ details = {"gradient_vectors_sampled" : len(gradient_vectors),
101
+ "eigenvalue_ratio" : float(eigenvalue_ratio),
102
+ "threshold" : GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD,
103
+ "original_pixels" : int(gx.size),
104
+ "filtered_vectors" : int(len(gradient_vectors)),
105
+ },
106
+ )
107
+
108
+ except Exception as e:
109
+ logger.error(f"Gradient PCA detection failed: {e}")
110
+
111
+ # Return neutral score on error
112
+ return MetricResult(metric_type = MetricType.GRADIENT,
113
+ score = GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE,
114
+ confidence = 0.0,
115
+ details = {"error" : "Gradient PCA detection failed"},
116
+ )
117
+
118
+
119
+ def _prepare_and_sample_gradients(self, gx: np.ndarray, gy: np.ndarray) -> np.ndarray:
120
+ """
121
+ Flatten gradients into vectors and sample
122
+
123
+ Arguments:
124
+ ----------
125
+ gx { np.ndarray } : Gradient in x direction
126
+
127
+ gy { np.ndarray } : Gradient in y direction
128
+
129
+ Returns:
130
+ --------
131
+ { np.ndarray } : Array of gradient vectors (N, 2) where N <= SAMPLE_SIZE
132
+ """
133
+ # Flatten to vectors
134
+ gx_flat = gx.flatten()
135
+ gy_flat = gy.flatten()
136
+
137
+ # Stack into (N, 2) array
138
+ gradient_vectors = np.stack([gx_flat, gy_flat], axis = 1)
139
+ original_n = len(gradient_vectors)
140
+
141
+ # Remove zero gradients (uniform regions)
142
+ magnitude = np.linalg.norm(gradient_vectors, axis = 1)
143
+ non_zero_mask = (magnitude > GRADIENT_FIELD_PCA_PARAMS.MAGNITUDE_THRESHOLD)
144
+ finite_mask = np.isfinite(gradient_vectors).all(axis = 1)
145
+
146
+ # Filtering Gradient Vector
147
+ filtered_gradient_vectors = gradient_vectors[non_zero_mask & finite_mask]
148
+ filtered_n = len(filtered_gradient_vectors)
149
+
150
+ # Sample if too many points without replacement
151
+ if (len(filtered_gradient_vectors) > GRADIENT_FIELD_PCA_PARAMS.SAMPLE_SIZE):
152
+ indices = self._range.choice(a = len(filtered_gradient_vectors),
153
+ size = GRADIENT_FIELD_PCA_PARAMS.SAMPLE_SIZE,
154
+ replace = False,
155
+ )
156
+
157
+ sampled_gradient_vectors = filtered_gradient_vectors[indices]
158
+
159
+ else:
160
+ sampled_gradient_vectors = filtered_gradient_vectors
161
+
162
+
163
+ sampled_n = len(sampled_gradient_vectors)
164
+
165
+ logger.debug(f"Gradient PCA sampling: original={original_n}, filtered={filtered_n}, sampled={sampled_n}")
166
+
167
+ return sampled_gradient_vectors
168
+
169
+
170
+ def _compute_eigenvalue_ratio(self, gradient_vectors: np.ndarray) -> float:
171
+ """
172
+ Compute ratio of first eigenvalue to total variance
173
+
174
+ - Lower ratio = more diffuse structure = suspicious
175
+ - Higher ratio = concentrated structure = natural
176
+
177
+ Arguments:
178
+ ----------
179
+ gradient_vectors { np.ndarray } : Array of gradient vectors (N, 2)
180
+
181
+ Returns:
182
+ --------
183
+ { float } : Ratio of first eigenvalue to sum of eigenvalues
184
+ """
185
+ if (len(gradient_vectors) < GRADIENT_FIELD_PCA_PARAMS.MIN_SAMPLES):
186
+ logger.warning("Insufficient gradient samples for PCA")
187
+ return GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE
188
+
189
+ # Compute covariance matrix
190
+ covariance = np.cov(m = gradient_vectors.T,
191
+ bias = True,
192
+ )
193
+
194
+ # Compute eigenvalues
195
+ eigenvalues = np.linalg.eigvalsh(covariance)
196
+
197
+ # Sort in descending order
198
+ eigenvalues = np.sort(eigenvalues)[::-1]
199
+
200
+ # Ratio of largest eigenvalue to sum
201
+ total_variance = np.sum(eigenvalues)
202
+
203
+ if (total_variance < GRADIENT_FIELD_PCA_PARAMS.VARIANCE_THRESHOLD):
204
+ return GRADIENT_FIELD_PCA_PARAMS.NEUTRAL_SCORE
205
+
206
+ eigenvalue_ratio = eigenvalues[0] / total_variance
207
+
208
+ return float(eigenvalue_ratio)
209
+
210
+
211
+ def _eigenvalue_to_suspicion(self, eigenvalue_ratio: float) -> float:
212
+ """
213
+ Convert eigenvalue ratio to suspicion score
214
+
215
+ - Real photos : High ratio (0.85-0.95) -> Low suspicion
216
+ - AI images : Low ratio (0.50-0.75) -> High suspicion
217
+
218
+ Arguments:
219
+ ----------
220
+ eigenvalue_ratio { float } : PCA eigenvalue ratio
221
+
222
+ Returns:
223
+ --------
224
+ { float } : Suspicion score [0.0, 1.0]
225
+ """
226
+ # Invert and scale: higher ratio = lower suspicion
227
+ # Real photos typically have ratio > 0.85 & AI images typically have ratio < 0.75
228
+ if (eigenvalue_ratio >= GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD):
229
+ # Strong gradient alignment = likely real
230
+ suspicion = max(0.0, (1.0 - eigenvalue_ratio) * 2.0)
231
+
232
+ else:
233
+ # Weak alignment = suspicious
234
+ suspicion = 1.0 - (eigenvalue_ratio / GRADIENT_FIELD_PCA_PARAMS.EIGENVALUE_RATIO_THRESHOLD)
235
+
236
+ return float(np.clip(suspicion, 0.0, 1.0))
metrics/noise_analyzer.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import numpy as np
3
+ from utils.logger import get_logger
4
+ from config.schemas import MetricResult
5
+ from config.constants import MetricType
6
+ from utils.image_processor import ImageProcessor
7
+ from config.constants import NOISE_ANALYSIS_PARAMS
8
+
9
+ # Suppress NumPy warning
10
+ np.seterr(divide = 'ignore',
11
+ invalid = 'ignore',
12
+ )
13
+
14
+
15
+ # Setup Logging
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class NoiseAnalyzer:
20
+ """
21
+ Noise pattern analysis for AI detection
22
+
23
+ Core principle:
24
+ ---------------
25
+ - Real photos : Sensor noise follows Poisson distribution (shot noise) + Gaussian (read noise)
26
+ - AI images : Too uniform, artificially smooth, or completely missing noise
27
+
28
+ Method:
29
+ -------
30
+ 1. Extract local patches
31
+ 2. Estimate noise variance in each patch
32
+ 3. Analyze noise consistency and distribution
33
+ 4. Check for unnatural uniformity
34
+ """
35
+ def __init__(self):
36
+ self.image_processor = ImageProcessor()
37
+
38
+
39
+ def detect(self, image: np.ndarray) -> MetricResult:
40
+ """
41
+ Run noise pattern analysis
42
+
43
+ Arguments:
44
+ ----------
45
+ image { np.ndarray } : RGB image array (H, W, 3)
46
+
47
+ Returns:
48
+ --------
49
+ { MetricResult } : Structured Noise-domain metric result containing:
50
+ - score : Suspicion score [0.0, 1.0]
51
+ - confidence : Reliability of noise evidence
52
+ - details : Noise related diagnostics
53
+ """
54
+ try:
55
+ logger.debug(f"Running noise analysis on image shape {image.shape}")
56
+
57
+ # Convert to luminance
58
+ luminance = self.image_processor.rgb_to_luminance(image = image)
59
+
60
+ # Extract patches
61
+ patches = self._extract_patches(luminance = luminance)
62
+
63
+ if (len(patches) == 0):
64
+ logger.warning("No patches extracted for noise analysis")
65
+ return MetricResult(metric_type = MetricType.NOISE,
66
+ score = NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
67
+ confidence = 0.0,
68
+ details = {"reason": "no_patches_extracted"},
69
+ )
70
+
71
+ # Estimate noise in each patch
72
+ noise_estimates, mad_values, laplacian_energy = self._estimate_noise_per_patch(patches = patches)
73
+
74
+ # Filter Noise Estimates, MAD and Laplacian Energy for finite values only
75
+ filtered_mask = np.isfinite(noise_estimates)
76
+ filtered_noise_estimates = noise_estimates[filtered_mask]
77
+ filtered_mad = mad_values[filtered_mask]
78
+ filtered_laplacian_energy = laplacian_energy[filtered_mask]
79
+
80
+ if (len(filtered_noise_estimates) < NOISE_ANALYSIS_PARAMS.MIN_ESTIMATES):
81
+ logger.debug("Insufficient valid noise estimates after filtering")
82
+
83
+ return MetricResult(metric_type = MetricType.NOISE,
84
+ score = NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
85
+ confidence = 0.0,
86
+ details = {"reason" : "insufficient_noise_estimates",
87
+ "patches_total" : int(len(patches)),
88
+ "patches_valid" : int(len(filtered_noise_estimates)),
89
+ },
90
+ )
91
+
92
+ logger.debug(f"Noise patches: total={len(patches)}, valid={len(filtered_noise_estimates)}")
93
+
94
+ # Analyze noise distribution
95
+ noise_score, noise_details = self._analyze_noise_distribution(noise_estimates = filtered_noise_estimates,
96
+ mad_values = filtered_mad,
97
+ laplacian_energy = filtered_laplacian_energy,
98
+ )
99
+
100
+ # Confidence: distance from neutral
101
+ confidence = float(np.clip((abs(noise_score - NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE) * 2.0), 0.0, 1.0))
102
+
103
+ logger.debug(f"Noise analysis: score={noise_score:.3f}, patches={len(patches)}, valid={len(filtered_noise_estimates)}")
104
+
105
+ return MetricResult(metric_type = MetricType.NOISE,
106
+ score = float(noise_score),
107
+ confidence = confidence,
108
+ details = {"patches_total" : int(len(patches)),
109
+ "patches_valid" : int(len(filtered_noise_estimates)),
110
+ **noise_details,
111
+ },
112
+ )
113
+
114
+ except Exception as e:
115
+ logger.error(f"Noise analysis failed: {e}")
116
+
117
+ # Return neutral score on error
118
+ return MetricResult(metric_type = MetricType.NOISE,
119
+ score = NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
120
+ confidence = 0.0,
121
+ details = {"error": "noise_analysis_failed"},
122
+ )
123
+
124
+
125
+ def _extract_patches(self, luminance: np.ndarray) -> np.ndarray:
126
+ """
127
+ Extract patches from image for local noise estimation
128
+
129
+ Arguments:
130
+ ----------
131
+ luminance { np.ndarray } : Luminance channel (H, W)
132
+
133
+ Returns:
134
+ --------
135
+ { np.ndarray } : Array of patches
136
+ """
137
+ patches = self.image_processor.extract_patches(image = luminance,
138
+ patch_size = NOISE_ANALYSIS_PARAMS.PATCH_SIZE,
139
+ stride = NOISE_ANALYSIS_PARAMS.STRIDE,
140
+ max_patches = NOISE_ANALYSIS_PARAMS.SAMPLES,
141
+ )
142
+
143
+ return patches
144
+
145
+
146
+ def _estimate_noise_per_patch(self, patches: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
147
+ """
148
+ Estimate noise variance in each patch using median absolute deviation
149
+
150
+ Uses Median Absolute Deviation (MAD) which is robust to edges/textures
151
+
152
+ Arguments:
153
+ ----------
154
+ patches { np.ndarray } : Array of image patches (N, patch_size, patch_size)
155
+
156
+ Returns:
157
+ --------
158
+ { tuple } : A tuple containing
159
+ - Array of noise estimates per patch
160
+ - Array of MAD values
161
+ - Array of Laplacian Energy Values
162
+ """
163
+ noise_estimates = list()
164
+ mad_values = list()
165
+ laplacian_energy_values = list()
166
+
167
+ for patch in patches:
168
+ # Skip patches with too much structure (edges, textures)
169
+ variance = np.var(patch)
170
+
171
+ if (variance < NOISE_ANALYSIS_PARAMS.VARIANCE_LOW_THRESHOLD):
172
+ # Too uniform, skip
173
+ continue
174
+
175
+ if (variance > NOISE_ANALYSIS_PARAMS.VARIANCE_HIGH_THRESHOLD):
176
+ # Too much structure, skip
177
+ continue
178
+
179
+ # Use Median Absolute Deviation for robust noise estimation
180
+ laplacian = self._apply_laplacian(patch = patch)
181
+ mad = np.median(np.abs(laplacian - np.median(laplacian)))
182
+
183
+ # Convert MAD to noise standard deviation estimate: For Gaussian noise: σ ≈ 1.4826 × MAD
184
+ noise_std = NOISE_ANALYSIS_PARAMS.MAD_TO_STD_FACTOR * mad
185
+
186
+ # Calculate Laplacian Energy
187
+ lap_energy = float(np.mean(laplacian ** 2))
188
+
189
+ # Append corresponding values to their storages
190
+ mad_values.append(mad)
191
+ noise_estimates.append(noise_std)
192
+ laplacian_energy_values.append(lap_energy)
193
+
194
+ return np.array(noise_estimates), np.array(mad_values), np.array(laplacian_energy_values)
195
+
196
+
197
+ def _apply_laplacian(self, patch: np.ndarray) -> np.ndarray:
198
+ """
199
+ Apply Laplacian filter to isolate high-frequency noise
200
+
201
+ Arguments:
202
+ ----------
203
+ patch { np.ndarray } : Image patch
204
+
205
+ Returns:
206
+ --------
207
+ { np.ndarray } : Laplacian-filtered patch
208
+ """
209
+ # Simple 3x3 Laplacian kernel
210
+ kernel = np.array([[0, 1, 0],
211
+ [1, -4, 1],
212
+ [0, 1, 0]],
213
+ )
214
+
215
+ # Pad patch
216
+ padded = np.pad(patch, 1, mode = 'reflect')
217
+
218
+ # Apply convolution
219
+ h, w = patch.shape
220
+ result = np.zeros_like(patch)
221
+
222
+ for i in range(h):
223
+ for j in range(w):
224
+ region = padded[i:i+3, j:j+3]
225
+ result[i, j] = np.sum(region * kernel)
226
+
227
+ return result
228
+
229
+
230
+ def _analyze_noise_distribution(self, noise_estimates: np.ndarray, mad_values: np.ndarray, laplacian_energy: np.ndarray,) -> tuple[float, dict]:
231
+ """
232
+ Analyze noise distribution for anomalies
233
+
234
+ Checks:
235
+ -------
236
+ 1. Coefficient of variation (consistency)
237
+ 2. Overall noise level (too low = suspicious)
238
+ 3. Distribution shape (too uniform = suspicious)
239
+
240
+ Arguments:
241
+ ----------
242
+ noise_estimates { np.ndarray } : Array of noise standard deviations
243
+
244
+ mad_values { np.ndarray } : Array of MAD values
245
+
246
+ laplacian_energy { np.ndarray } : Array of Laplacian Energy Values
247
+
248
+ Returns:
249
+ --------
250
+ { tuple } : A tuple containing:
251
+ - Suspicion score [0.0, 1.0]
252
+ - Noise Distribution detailed diagnostics
253
+ """
254
+ if (len(noise_estimates) < NOISE_ANALYSIS_PARAMS.MIN_ESTIMATES):
255
+ return (NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
256
+ {"reason": "insufficient_noise_samples"},
257
+ )
258
+
259
+ # Remove outliers (keep middle 80%)
260
+ q10 = np.percentile(noise_estimates, NOISE_ANALYSIS_PARAMS.OUTLIER_PERCENTILE_LOW)
261
+ q90 = np.percentile(noise_estimates, NOISE_ANALYSIS_PARAMS.OUTLIER_PERCENTILE_HIGH)
262
+ filtered = noise_estimates[(noise_estimates >= q10) & (noise_estimates <= q90)]
263
+
264
+ if (len(filtered) < NOISE_ANALYSIS_PARAMS.MIN_FILTERED_SAMPLES):
265
+ return (NOISE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
266
+ {"reason": "insufficient_filtered_samples"},
267
+ )
268
+
269
+ mean_noise = np.mean(filtered)
270
+ std_noise = np.std(filtered)
271
+
272
+ # Coefficient of Variation (CV) Analysis
273
+ cv = std_noise / (mean_noise + 1e-10)
274
+ cv_anomaly = 0.0
275
+
276
+ if (cv < NOISE_ANALYSIS_PARAMS.CV_UNIFORM_THRESHOLD):
277
+ # Too uniform
278
+ cv_anomaly = (NOISE_ANALYSIS_PARAMS.CV_UNIFORM_THRESHOLD - cv) * NOISE_ANALYSIS_PARAMS.CV_UNIFORM_SCALE
279
+
280
+ elif (cv > NOISE_ANALYSIS_PARAMS.CV_VARIABLE_THRESHOLD):
281
+ # Too variable
282
+ cv_anomaly = min(1.0, (cv - NOISE_ANALYSIS_PARAMS.CV_VARIABLE_THRESHOLD) * NOISE_ANALYSIS_PARAMS.CV_VARIABLE_SCALE)
283
+
284
+ # Overall noise level Analysis
285
+ noise_level_anomaly = 0.0
286
+
287
+ if (mean_noise < NOISE_ANALYSIS_PARAMS.LEVEL_CLEAN_THRESHOLD):
288
+ # Too clean
289
+ noise_level_anomaly = (NOISE_ANALYSIS_PARAMS.LEVEL_CLEAN_THRESHOLD - mean_noise) / NOISE_ANALYSIS_PARAMS.LEVEL_CLEAN_THRESHOLD
290
+
291
+ elif (mean_noise < NOISE_ANALYSIS_PARAMS.LEVEL_LOW_THRESHOLD):
292
+ # Slightly low
293
+ noise_level_anomaly = (NOISE_ANALYSIS_PARAMS.LEVEL_LOW_THRESHOLD - mean_noise) / NOISE_ANALYSIS_PARAMS.LEVEL_LOW_THRESHOLD * 0.5
294
+
295
+
296
+ # Distribution shape Analysis
297
+ q25 = np.percentile(filtered, NOISE_ANALYSIS_PARAMS.IQR_PERCENTILE_LOW)
298
+ q75 = np.percentile(filtered, NOISE_ANALYSIS_PARAMS.IQR_PERCENTILE_HIGH)
299
+ iqr = q75 - q25
300
+ iqr_ratio = iqr / (mean_noise + 1e-10)
301
+
302
+ iqr_anomaly = 0.0
303
+
304
+ if (iqr_ratio < NOISE_ANALYSIS_PARAMS.IQR_THRESHOLD):
305
+ iqr_anomaly = (NOISE_ANALYSIS_PARAMS.IQR_THRESHOLD - iqr_ratio) * NOISE_ANALYSIS_PARAMS.IQR_SCALE
306
+
307
+ # Clip sub-anomalies for safety
308
+ cv_anomaly = np.clip(cv_anomaly, 0.0, 1.0)
309
+ noise_level_anomaly = np.clip(noise_level_anomaly, 0.0, 1.0)
310
+ iqr_anomaly = np.clip(iqr_anomaly, 0.0, 1.0)
311
+
312
+ # Combine scores
313
+ weights = NOISE_ANALYSIS_PARAMS.SUBMETRIC_WEIGHTS
314
+ combined_score = (weights['cv_anomaly'] * cv_anomaly + weights['noise_level_anomaly'] * noise_level_anomaly + weights['iqr_anomaly'] * iqr_anomaly)
315
+ final_score = float(np.clip(combined_score, 0.0, 1.0))
316
+
317
+ # Calculate Forensic Stats
318
+ mad_mean = float(np.mean(mad_values)) if len(mad_values) else 0.0
319
+ laplacian_energy_mu = float(np.mean(laplacian_energy)) if len(laplacian_energy) else 0.0
320
+
321
+ noise_details_dict = {"mean_noise" : float(mean_noise),
322
+ "std_noise" : float(std_noise),
323
+ "cv" : float(cv),
324
+ "cv_anomaly" : float(cv_anomaly),
325
+ "noise_level_anomaly" : float(noise_level_anomaly),
326
+ "iqr_ratio" : float(iqr_ratio),
327
+ "iqr_anomaly" : float(iqr_anomaly),
328
+ "mad_mean" : mad_mean,
329
+ "laplacian_energy" : laplacian_energy_mu,
330
+ }
331
+
332
+ logger.debug(f"Noise scores - CV: {cv:.3f}, mean: {mean_noise:.3f}, IQR ratio: {iqr_ratio:.3f}")
333
+
334
+ return final_score, noise_details_dict
335
+
metrics/texture_analyzer.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import numpy as np
3
+ from scipy.stats import entropy
4
+ from utils.logger import get_logger
5
+ from config.schemas import MetricResult
6
+ from config.constants import MetricType
7
+ from utils.image_processor import ImageProcessor
8
+ from config.constants import TEXTURE_ANALYSIS_PARAMS
9
+
10
+ # Suppress NumPy warning
11
+ np.seterr(divide = 'ignore',
12
+ invalid = 'ignore',
13
+ )
14
+
15
+
16
+ # Setup Logging
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class TextureAnalyzer:
21
+ """
22
+ Statistical texture analysis for AI detection
23
+
24
+ Core principle:
25
+ ---------------
26
+ - Real photos : Natural texture variation (random but structured)
27
+ - AI images : Either too smooth or repetitive patterns
28
+
29
+ Method:
30
+ -------
31
+ 1. Extract local patches
32
+ 2. Compute texture features (contrast, entropy)
33
+ 3. Analyze texture consistency and distribution
34
+ 4. Detect unnaturally smooth regions
35
+ """
36
+ def __init__(self):
37
+ """
38
+ Initialize TextureAnalyzer Class
39
+ """
40
+ self.patch_size = TEXTURE_ANALYSIS_PARAMS.PATCH_SIZE
41
+ self.n_patches = TEXTURE_ANALYSIS_PARAMS.N_PATCHES
42
+ self.image_processor = ImageProcessor()
43
+ self._rng = np.random.default_rng(seed = TEXTURE_ANALYSIS_PARAMS.RANDOM_SEED)
44
+
45
+
46
+ def detect(self, image: np.ndarray) -> MetricResult:
47
+ """
48
+ Run texture analysis
49
+
50
+ Arguments:
51
+ ----------
52
+ image { np.ndarray } : RGB image array (H, W, 3)
53
+
54
+ Returns:
55
+ --------
56
+ { MetricResult } : Structured Texture-domain metric result containing:
57
+ - score : Suspicion score [0.0, 1.0]
58
+ - confidence : Reliability of texture evidence
59
+ - details : Texture forensics and statistics
60
+ """
61
+ try:
62
+ logger.debug(f"Running texture analysis on image shape {image.shape}")
63
+
64
+ # Convert to luminance
65
+ luminance = self.image_processor.rgb_to_luminance(image = image)
66
+
67
+ # Extract patches
68
+ patches = self._extract_patches(luminance = luminance)
69
+
70
+ if (len(patches) == 0):
71
+ logger.warning("No patches extracted for texture analysis")
72
+ return MetricResult(metric_type = MetricType.TEXTURE,
73
+ score = TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
74
+ confidence = 0.0,
75
+ details = {"reason": "no_patches_extracted"},
76
+ )
77
+
78
+ # Compute texture features
79
+ texture_features, texture_metadata = self._compute_texture_features(patches = patches)
80
+
81
+ # Analyze for anomalies
82
+ texture_score, texture_details = self._analyze_texture_anomalies(features = texture_features,
83
+ metadata = texture_metadata,
84
+ )
85
+
86
+ # Calculate Confidence
87
+ confidence = float(np.clip((abs(texture_score - TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE) * 2.0), 0.0, 1.0))
88
+
89
+ logger.debug(f"Texture analysis: Texture Score={texture_score:.3f}, patches={len(patches)}")
90
+
91
+ return MetricResult(metric_type = MetricType.TEXTURE,
92
+ score = float(texture_score),
93
+ confidence = confidence,
94
+ details = {"patches_total" : int(len(patches)),
95
+ **texture_metadata,
96
+ **texture_details,
97
+ },
98
+ )
99
+
100
+ except Exception as e:
101
+ logger.error(f"Texture analysis failed: {e}")
102
+
103
+ # Return neutral score on error
104
+ return MetricResult(metric_type = MetricType.TEXTURE,
105
+ score = TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
106
+ confidence = 0.0,
107
+ details = {"error": "texture_analysis_failed"},
108
+ )
109
+
110
+
111
+ def _extract_patches(self, luminance: np.ndarray) -> np.ndarray:
112
+ """
113
+ Extract random patches from image
114
+ """
115
+ h, w = luminance.shape
116
+
117
+ if ((h < self.patch_size) or (w < self.patch_size)):
118
+ logger.warning(f"Image too small for patch size {self.patch_size}")
119
+ return np.array([])
120
+
121
+ patches = list()
122
+
123
+ for _ in range(self.n_patches):
124
+ y = self._rng.integers(0, h - self.patch_size)
125
+ x = self._rng.integers(0, w - self.patch_size)
126
+
127
+ patch = luminance[y:y+self.patch_size, x:x+self.patch_size]
128
+ patches.append(patch)
129
+
130
+ return np.array(patches)
131
+
132
+
133
+ def _compute_texture_features(self, patches: np.ndarray) -> tuple[dict, dict]:
134
+ """
135
+ Compute texture features for each patch
136
+
137
+ Features:
138
+ ---------
139
+ 1. Local contrast (standard deviation)
140
+ 2. Entropy (randomness)
141
+ 3. Smoothness (inverse of variance)
142
+ 4. Edge density
143
+
144
+ Arguments:
145
+ ----------
146
+ patches { np.ndarray } : Array of patches
147
+
148
+ Returns:
149
+ --------
150
+ { tuple } : A tuple containing
151
+ - A dictionary of feature arrays
152
+ - A dictionary of texture analysis metadata
153
+ """
154
+ contrasts = list()
155
+ entropies = list()
156
+ smoothnesses = list()
157
+ edge_densities = list()
158
+ uniform_skipped = 0
159
+
160
+ for patch in patches:
161
+ pmin = patch.min()
162
+ pmax = patch.max()
163
+
164
+ if ((pmax - pmin < 1e-6)):
165
+ # skip fully uniform patch entirely
166
+ uniform_skipped += 1
167
+ continue
168
+
169
+ # Contrast (std deviation)
170
+ contrast = np.std(patch)
171
+ contrasts.append(contrast)
172
+
173
+ # Entropy (using histogram)
174
+ hist, _ = np.histogram(patch,
175
+ bins = TEXTURE_ANALYSIS_PARAMS.HISTOGRAM_BINS,
176
+ range = TEXTURE_ANALYSIS_PARAMS.HISTOGRAM_RANGE,
177
+ )
178
+
179
+ hist = hist / (np.sum(hist) + 1e-10)
180
+ ent = entropy(hist + 1e-10)
181
+ entropies.append(ent)
182
+
183
+ # Smoothness (inverse of variance, scaled)
184
+ variance = np.var(patch)
185
+ smoothness = 1.0 / (1.0 + variance)
186
+ smoothnesses.append(smoothness)
187
+
188
+ # Edge density (using Sobel)
189
+ gx, gy = self.image_processor.compute_gradients(luminance = patch)
190
+ gradient_mag = np.sqrt(gx**2 + gy**2)
191
+
192
+ edge_density = np.mean(gradient_mag > TEXTURE_ANALYSIS_PARAMS.EDGE_THRESHOLD)
193
+ edge_densities.append(edge_density)
194
+
195
+ # Construct results in proper format
196
+ features = {"contrast" : np.array(contrasts),
197
+ "entropy" : np.array(entropies),
198
+ "smoothness" : np.array(smoothnesses),
199
+ "edge_density" : np.array(edge_densities),
200
+ }
201
+
202
+ metadata = {"patches_used" : int(len(contrasts)),
203
+ "uniform_patches_skipped" : int(uniform_skipped),
204
+ }
205
+
206
+
207
+ return features, metadata
208
+
209
+
210
+ def _analyze_texture_anomalies(self, features: dict, metadata: dict) -> tuple[float, dict]:
211
+ """
212
+ Analyze texture features for AI generation artifacts
213
+
214
+ Checks:
215
+ -------
216
+ 1. Excessive smoothness (too many overly smooth patches)
217
+ 2. Entropy distribution (too uniform = suspicious)
218
+ 3. Contrast consistency
219
+
220
+ Arguments:
221
+ ----------
222
+ features { dict } : Dictionary of texture features
223
+
224
+ metadata { dict } : Dictionary of texture analysis metadata
225
+
226
+ Returns:
227
+ --------
228
+ { tuple } : A tuple containing:
229
+ - Suspicion score [0.0, 1.0]
230
+ - Texture statistics
231
+ """
232
+ contrast = features['contrast']
233
+ entropy_vals = features['entropy']
234
+ smoothness = features['smoothness']
235
+ edge_density = features['edge_density']
236
+
237
+ if ((len(contrast) == 0) or (len(entropy_vals) == 0) or (len(smoothness) == 0) or (len(edge_density) == 0)):
238
+ logger.debug("All texture features filtered out; returning neutral score")
239
+ return (TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
240
+ {"reason": "all_texture_features_filtered"},
241
+ )
242
+
243
+ # Early exit: all patches nearly uniform
244
+ if (np.all(contrast < 1e-6)):
245
+ logger.debug("All texture patches near-uniform; returning neutral score")
246
+ return (TEXTURE_ANALYSIS_PARAMS.NEUTRAL_SCORE,
247
+ {"reason": "all_patches_near_uniform"},
248
+ )
249
+
250
+ # Smoothness Analysis
251
+ smooth_ratio = np.mean(smoothness > TEXTURE_ANALYSIS_PARAMS.SMOOTHNESS_THRESHOLD)
252
+ smoothness_anomaly = 0.0
253
+
254
+ if (smooth_ratio > TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_THRESHOLD):
255
+ # More than 40% very smooth patches
256
+ smoothness_anomaly = min(1.0, (smooth_ratio - TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_THRESHOLD) * TEXTURE_ANALYSIS_PARAMS.SMOOTH_RATIO_SCALE)
257
+
258
+ # Entropy distribution Analysis
259
+ entropy_cv = np.std(entropy_vals) / (np.mean(entropy_vals) + 1e-10)
260
+ entropy_anomaly = 0.0
261
+
262
+ if (entropy_cv < TEXTURE_ANALYSIS_PARAMS.ENTROPY_CV_THRESHOLD):
263
+ # Too uniform
264
+ entropy_anomaly = (TEXTURE_ANALYSIS_PARAMS.ENTROPY_CV_THRESHOLD - entropy_cv) * TEXTURE_ANALYSIS_PARAMS.ENTROPY_SCALE
265
+
266
+ # Contrast distribution Analysis
267
+ contrast_cv = np.std(contrast) / (np.mean(contrast) + 1e-10)
268
+ contrast_anomaly = 0.0
269
+
270
+ if (contrast_cv < TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_LOW):
271
+ # Too uniform
272
+ contrast_anomaly = (TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_LOW - contrast_cv) * TEXTURE_ANALYSIS_PARAMS.CONTRAST_LOW_SCALE
273
+
274
+ elif (contrast_cv > TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_HIGH):
275
+ # Too variable (suspicious)
276
+ contrast_anomaly = min(1.0, (contrast_cv - TEXTURE_ANALYSIS_PARAMS.CONTRAST_CV_HIGH) * TEXTURE_ANALYSIS_PARAMS.CONTRAST_HIGH_SCALE)
277
+
278
+
279
+ # Edge density consistency Analysis
280
+ edge_cv = np.std(edge_density) / (np.mean(edge_density) + 1e-10)
281
+ edge_anomaly = 0.0
282
+
283
+ if (edge_cv < TEXTURE_ANALYSIS_PARAMS.EDGE_CV_THRESHOLD):
284
+ edge_anomaly = (TEXTURE_ANALYSIS_PARAMS.EDGE_CV_THRESHOLD - edge_cv) * TEXTURE_ANALYSIS_PARAMS.EDGE_SCALE
285
+
286
+ # Clipping Sub-anomalies
287
+ smoothness_anomaly = np.clip(smoothness_anomaly, 0.0, 1.0)
288
+ entropy_anomaly = np.clip(entropy_anomaly, 0.0, 1.0)
289
+ contrast_anomaly = np.clip(contrast_anomaly, 0.0, 1.0)
290
+ edge_anomaly = np.clip(edge_anomaly, 0.0, 1.0)
291
+
292
+ # Combine scores
293
+ weights = TEXTURE_ANALYSIS_PARAMS.SUBMETRIC_WEIGHTS
294
+ texture_score = (weights['smoothness_anomaly'] * smoothness_anomaly + weights['entropy_anomaly'] * entropy_anomaly + weights['contrast_anomaly'] * contrast_anomaly + weights['edge_anomaly'] * edge_anomaly)
295
+ final_score = float(np.clip(texture_score, 0.0, 1.0))
296
+
297
+ detailed_stats = {"smooth_ratio" : float(smooth_ratio),
298
+ "entropy_mean" : float(np.mean(entropy_vals)),
299
+ "entropy_cv" : float(entropy_cv),
300
+ "contrast_mean" : float(np.mean(contrast)),
301
+ "contrast_cv" : float(contrast_cv),
302
+ "edge_density_mean" : float(np.mean(edge_density)),
303
+ "edge_cv" : float(edge_cv),
304
+ }
305
+
306
+ logger.debug(f"Texture scores - smoothness: {smoothness_anomaly:.3f}, entropy: {entropy_anomaly:.3f}, contrast: {contrast_anomaly:.3f}, edge: {edge_anomaly:.3f}")
307
+
308
+ return final_score, detailed_stats
reporter/__init__.py ADDED
File without changes
reporter/csv_reporter.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import csv
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from datetime import datetime
6
+ from utils.logger import get_logger
7
+ from config.settings import settings
8
+ from config.constants import MetricType
9
+ from config.schemas import AnalysisResult
10
+ from utils.helpers import generate_unique_id
11
+ from config.constants import DetectionStatus
12
+ from config.schemas import BatchAnalysisResult
13
+ from features.detailed_result_maker import DetailedResultMaker
14
+
15
+
16
+ # Setup Logging
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class CSVReporter:
21
+ """
22
+ Professional CSV report generator
23
+
24
+ Features:
25
+ ---------
26
+ - Single image detailed reports
27
+ - Batch summary reports with statistics
28
+ - Detailed forensic data export
29
+ - Excel-compatible formatting
30
+ - UTF-8 encoding with BOM for international compatibility
31
+ """
32
+ def __init__(self):
33
+ """
34
+ Initialize CSV Reporter
35
+ """
36
+ self.detailed_maker = DetailedResultMaker()
37
+ logger.debug("CSVReporter initialized")
38
+
39
+
40
+ def export_batch_summary(self, batch_result: BatchAnalysisResult, output_dir: Optional[Path] = None) -> Path:
41
+ """
42
+ Export batch analysis summary as CSV
43
+
44
+ Arguments:
45
+ ----------
46
+ batch_result { BatchAnalysisResult } : Complete batch analysis result
47
+
48
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
49
+
50
+ Returns:
51
+ --------
52
+ { Path } : Path to generated CSV file
53
+ """
54
+ output_dir = output_dir or settings.REPORTS_DIR
55
+ report_id = generate_unique_id()
56
+ filename = f"batch_summary_{report_id}.csv"
57
+ output_path = output_dir / filename
58
+
59
+ logger.info(f"Generating batch summary CSV: {filename}")
60
+
61
+ try:
62
+ with open(output_path, 'w', newline = '', encoding = 'utf-8-sig') as f:
63
+ writer = csv.writer(f)
64
+
65
+ # Report Header
66
+ self._write_report_header(writer = writer,
67
+ report_type = "Batch Analysis Summary",
68
+ timestamp = batch_result.timestamp,
69
+ )
70
+
71
+ # Batch Statistics
72
+ self._write_batch_statistics(writer = writer,
73
+ batch_result = batch_result,
74
+ )
75
+
76
+ # Main Results Table
77
+ self._write_batch_results_table(writer = writer,
78
+ batch_result = batch_result,
79
+ )
80
+
81
+ # Footer
82
+ self._write_footer(writer = writer)
83
+
84
+ logger.info(f"Batch summary CSV generated: {output_path}")
85
+ return output_path
86
+
87
+ except Exception as e:
88
+ logger.error(f"Failed to generate batch summary CSV: {e}")
89
+ raise
90
+
91
+
92
+ def export_batch_detailed(self, batch_result: BatchAnalysisResult, output_dir: Optional[Path] = None) -> Path:
93
+ """
94
+ Export detailed batch analysis with forensic data
95
+
96
+ Arguments:
97
+ ----------
98
+ batch_result { BatchAnalysisResult } : Complete batch analysis result
99
+
100
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
101
+
102
+ Returns:
103
+ --------
104
+ { Path } : Path to generated CSV file
105
+ """
106
+ output_dir = output_dir or settings.REPORTS_DIR
107
+ report_id = generate_unique_id()
108
+ filename = f"batch_detailed_{report_id}.csv"
109
+ output_path = output_dir / filename
110
+
111
+ logger.info(f"Generating detailed batch CSV: {filename}")
112
+
113
+ try:
114
+ with open(output_path, 'w', newline = '', encoding = 'utf-8-sig') as f:
115
+ writer = csv.writer(f)
116
+
117
+ # Report Header
118
+ self._write_report_header(writer = writer,
119
+ report_type = "Detailed Batch Analysis",
120
+ timestamp = batch_result.timestamp,
121
+ )
122
+
123
+ # Process each image with full details
124
+ for idx, result in enumerate(batch_result.results, 1):
125
+ self._write_detailed_image_section(writer = writer,
126
+ result = result,
127
+ image_number = idx,
128
+ total_images = batch_result.processed,
129
+ )
130
+
131
+ # Add separator between images
132
+ if (idx < batch_result.processed):
133
+ writer.writerow([])
134
+ writer.writerow(['=' * 100])
135
+ writer.writerow([])
136
+
137
+ # Footer
138
+ self._write_footer(writer = writer)
139
+
140
+ logger.info(f"Detailed batch CSV generated: {output_path}")
141
+ return output_path
142
+
143
+ except Exception as e:
144
+ logger.error(f"Failed to generate detailed batch CSV: {e}")
145
+ raise
146
+
147
+
148
+ def export_single_detailed(self, result: AnalysisResult, output_dir: Optional[Path] = None) -> Path:
149
+ """
150
+ Export single image detailed analysis as CSV
151
+
152
+ Arguments:
153
+ ----------
154
+ result { AnalysisResult } : Single image analysis result
155
+
156
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
157
+
158
+ Returns:
159
+ --------
160
+ { Path } : Path to generated CSV file
161
+ """
162
+ output_dir = output_dir or settings.REPORTS_DIR
163
+ report_id = generate_unique_id()
164
+ filename = f"single_analysis_{report_id}.csv"
165
+ output_path = output_dir / filename
166
+
167
+ logger.info(f"Generating single image CSV: {filename}")
168
+
169
+ try:
170
+ with open(output_path, 'w', newline = '', encoding = 'utf-8-sig') as f:
171
+ writer = csv.writer(f)
172
+
173
+ # Report Header
174
+ self._write_report_header(writer = writer,
175
+ report_type = "Single Image Analysis",
176
+ timestamp = result.timestamp,
177
+ )
178
+
179
+ # Image Details
180
+ self._write_detailed_image_section(writer = writer,
181
+ result = result,
182
+ image_number = 1,
183
+ total_images = 1,
184
+ )
185
+
186
+ # Footer
187
+ self._write_footer(writer = writer)
188
+
189
+ logger.info(f"Single image CSV generated: {output_path}")
190
+ return output_path
191
+
192
+ except Exception as e:
193
+ logger.error(f"Failed to generate single image CSV: {e}")
194
+ raise
195
+
196
+
197
+ def export_metrics_comparison(self, batch_result: BatchAnalysisResult, output_dir: Optional[Path] = None) -> Path:
198
+ """
199
+ Export metrics comparison table across all images
200
+
201
+ Arguments:
202
+ ----------
203
+ batch_result { BatchAnalysisResult } : Complete batch analysis result
204
+
205
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
206
+
207
+ Returns:
208
+ --------
209
+ { Path } : Path to generated CSV file
210
+ """
211
+ output_dir = output_dir or settings.REPORTS_DIR
212
+ report_id = generate_unique_id()
213
+ filename = f"metrics_comparison_{report_id}.csv"
214
+ output_path = output_dir / filename
215
+
216
+ logger.info(f"Generating metrics comparison CSV: {filename}")
217
+
218
+ try:
219
+ with open(output_path, 'w', newline = '', encoding = 'utf-8-sig') as f:
220
+ writer = csv.writer(f)
221
+
222
+ # Report Header
223
+ self._write_report_header(writer = writer,
224
+ report_type = "Metrics Comparison",
225
+ timestamp = batch_result.timestamp,
226
+ )
227
+
228
+ # Comparison Table Header
229
+ writer.writerow(['Metrics Comparison Across All Images'])
230
+ writer.writerow([])
231
+
232
+ header = ['Filename',
233
+ 'Overall Score',
234
+ 'Analysis Status',
235
+ 'Gradient Analysis Score',
236
+ 'Gradient Analysis Confidence',
237
+ 'Frequency Analysis Score',
238
+ 'Frequency Analysis Confidence',
239
+ 'Noise Analysis Score',
240
+ 'Noise Analysis Confidence',
241
+ 'Texture Analysis Score',
242
+ 'Texture Analysis Confidence',
243
+ 'Color Analysis Score',
244
+ 'Color Analysis Confidence',
245
+ 'Processing Time',
246
+ ]
247
+
248
+ writer.writerow(header)
249
+
250
+ # Data rows
251
+ for result in batch_result.results:
252
+ row = [result.filename,
253
+ f"{result.overall_score:.3f}",
254
+ result.status.value,
255
+ ]
256
+
257
+ # Add each metric's score and confidence
258
+ for metric_type in [MetricType.GRADIENT, MetricType.FREQUENCY, MetricType.NOISE, MetricType.TEXTURE, MetricType.COLOR]:
259
+ metric_result = result.metric_results.get(metric_type)
260
+
261
+ if metric_result:
262
+ row.append(f"{metric_result.score:.3f}")
263
+ row.append(f"{metric_result.confidence:.3f}" if metric_result.confidence is not None else "N/A")
264
+
265
+ else:
266
+ row.extend(["N/A", "N/A"])
267
+
268
+ row.append(f"{result.processing_time:.2f}s")
269
+ writer.writerow(row)
270
+
271
+ # Footer
272
+ writer.writerow([])
273
+ self._write_footer(writer = writer)
274
+
275
+ logger.info(f"Metrics comparison CSV generated: {output_path}")
276
+ return output_path
277
+
278
+ except Exception as e:
279
+ logger.error(f"Failed to generate metrics comparison CSV: {e}")
280
+ raise
281
+
282
+
283
+ def _write_report_header(self, writer, report_type: str, timestamp: datetime) -> None:
284
+ """
285
+ Write CSV report header
286
+ """
287
+ writer.writerow(['=' * 100])
288
+ writer.writerow([f'AI Image Screener - {report_type}'])
289
+ writer.writerow([f'Generated: {timestamp.strftime("%Y-%m-%d %H:%M:%S")}'])
290
+ writer.writerow([f'Version: {settings.VERSION}'])
291
+ writer.writerow(['=' * 100])
292
+ writer.writerow([])
293
+
294
+
295
+ def _write_batch_statistics(self, writer, batch_result: BatchAnalysisResult) -> None:
296
+ """
297
+ Write batch statistics section
298
+ """
299
+ writer.writerow(['BATCH STATISTICS'])
300
+ writer.writerow([])
301
+
302
+ stats = [['Total Images', batch_result.total_images],
303
+ ['Successfully Processed', batch_result.processed],
304
+ ['Failed', batch_result.failed],
305
+ ['Success Rate', f"{batch_result.summary.get('success_rate', 0)}%"],
306
+ ['' , ''],
307
+ ['Likely Authentic', batch_result.summary.get('likely_authentic', 0)],
308
+ ['Review Required', batch_result.summary.get('review_required', 0)],
309
+ ['', ''],
310
+ ['Average Score', f"{batch_result.summary.get('avg_score', 0):.3f}"],
311
+ ['Average Confidence', f"{batch_result.summary.get('avg_confidence', 0)}%"],
312
+ ['Total Processing Time', f"{batch_result.total_processing_time:.2f}s"],
313
+ ['Average Time per Image', f"{batch_result.summary.get('avg_proc_time', 0):.2f}s"],
314
+ ]
315
+
316
+ for row in stats:
317
+ writer.writerow(row)
318
+
319
+ writer.writerow([])
320
+ writer.writerow(['=' * 100])
321
+ writer.writerow([])
322
+
323
+
324
+ def _write_batch_results_table(self, writer, batch_result: BatchAnalysisResult) -> None:
325
+ """
326
+ Write batch results main table
327
+ """
328
+ writer.writerow(['ANALYSIS RESULTS'])
329
+ writer.writerow([])
330
+
331
+ # Table Header
332
+ header = ['Filename',
333
+ 'Image Size',
334
+ 'Analysis Status',
335
+ 'Overall Score',
336
+ 'Analysis Confidence (%)',
337
+ 'Top Warning Signals',
338
+ 'Recommendation',
339
+ 'Processing Time (s)',
340
+ ]
341
+
342
+ writer.writerow(header)
343
+
344
+ # Data rows
345
+ for result in batch_result.results:
346
+ # Get top warning signals
347
+ top_signals = [s.name for s in result.signals if s.status.value in ['flagged', 'warning']][:2]
348
+ signals_str = "; ".join(top_signals) if top_signals else "All tests passed"
349
+
350
+ # Recommendation
351
+ if (result.status == DetectionStatus.REVIEW_REQUIRED):
352
+ recommendation = "Manual verification recommended"
353
+
354
+ else:
355
+ recommendation = "No further action needed"
356
+
357
+ row = [result.filename,
358
+ f"{result.image_size[0]}×{result.image_size[1]}",
359
+ result.status.value,
360
+ f"{result.overall_score:.3f}",
361
+ f"{result.confidence}%",
362
+ signals_str,
363
+ recommendation,
364
+ f"{result.processing_time:.2f}",
365
+ ]
366
+
367
+ writer.writerow(row)
368
+
369
+ writer.writerow([])
370
+
371
+
372
+ def _write_detailed_image_section(self, writer, result: AnalysisResult, image_number: int, total_images: int) -> None:
373
+ """
374
+ Write detailed section for single image
375
+ """
376
+ writer.writerow([f'IMAGE {image_number} OF {total_images}'])
377
+ writer.writerow([])
378
+
379
+ # Basic Information
380
+ writer.writerow(['BASIC INFORMATION'])
381
+ writer.writerow(['Filename', result.filename])
382
+ writer.writerow(['Status', result.status.value])
383
+ writer.writerow(['Overall Score', f"{result.overall_score:.3f}"])
384
+ writer.writerow(['Confidence', f"{result.confidence}%"])
385
+ writer.writerow(['Image Size', f"{result.image_size[0]}×{result.image_size[1]}"])
386
+ writer.writerow(['Processing Time', f"{result.processing_time:.2f}s"])
387
+ writer.writerow(['Timestamp', result.timestamp.isoformat()])
388
+ writer.writerow([])
389
+
390
+ # Detection Signals
391
+ writer.writerow(['DETECTION SIGNALS'])
392
+ writer.writerow([])
393
+ writer.writerow(['Metric Name', 'Metric Score', 'Analysis Status', 'Metric Confidence', 'Metric Explanation'])
394
+
395
+ for signal in result.signals:
396
+ metric_result = result.metric_results.get(signal.metric_type)
397
+ confidence_str = f"{metric_result.confidence:.3f}" if metric_result.confidence is not None else "N/A"
398
+
399
+ writer.writerow([signal.name,
400
+ f"{signal.score:.3f}",
401
+ signal.status.value.upper(),
402
+ confidence_str,
403
+ signal.explanation.replace("\n", " "),
404
+ ])
405
+
406
+ writer.writerow([])
407
+
408
+ # Detailed Forensics
409
+ writer.writerow(['FORENSIC DETAILS'])
410
+ writer.writerow([])
411
+
412
+ for metric_type in MetricType:
413
+ metric_result = result.metric_results.get(metric_type)
414
+
415
+ if not metric_result:
416
+ continue
417
+
418
+ metric_name = self.detailed_maker.metric_display_names.get(metric_type, metric_type.value)
419
+
420
+ writer.writerow([f'--- {metric_name} ---'])
421
+ writer.writerow(['Score', f"{metric_result.score:.3f}"])
422
+ writer.writerow(['Confidence', f"{metric_result.confidence:.3f}" if metric_result.confidence is not None else "N/A"])
423
+
424
+ # Write details
425
+ if metric_result.details:
426
+ for key, value in metric_result.details.items():
427
+ if isinstance(value, dict):
428
+ writer.writerow([f" {key}:", ""])
429
+ for sub_key, sub_value in value.items():
430
+ writer.writerow([f" {sub_key}", str(sub_value)])
431
+
432
+ else:
433
+ writer.writerow([f" {key}", str(value)])
434
+
435
+ writer.writerow([])
436
+
437
+ # Recommendation
438
+ writer.writerow(['RECOMMENDATION'])
439
+ writer.writerow([])
440
+
441
+ if (result.status == DetectionStatus.REVIEW_REQUIRED):
442
+ writer.writerow(['Action', 'Manual verification recommended'])
443
+ writer.writerow(['Priority', 'HIGH' if (result.overall_score >= 0.85) else 'MEDIUM'])
444
+ writer.writerow(['Next Steps', 'Forensic analysis, reverse image search, metadata inspection'])
445
+
446
+ else:
447
+ writer.writerow(['Action', 'No immediate action needed'])
448
+ writer.writerow(['Priority', 'LOW'])
449
+ writer.writerow(['Next Steps', 'Proceed with normal workflow'])
450
+
451
+ writer.writerow([])
452
+
453
+
454
+ def _write_footer(self, writer) -> None:
455
+ """
456
+ Write CSV report footer
457
+ """
458
+ writer.writerow(['=' * 100])
459
+ writer.writerow(['Report generated by AI Image Screener'])
460
+ writer.writerow(['For questions or support, contact: support@aiimagescreener.com'])
461
+ writer.writerow(['DISCLAIMER: Results are indicative and should be verified manually for critical applications'])
462
+ writer.writerow(['=' * 100])
reporter/json_reporter.py ADDED
@@ -0,0 +1,349 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import json
3
+ from typing import Dict
4
+ from typing import List
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ from datetime import datetime
8
+ from utils.logger import get_logger
9
+ from config.settings import settings
10
+ from config.schemas import AnalysisResult
11
+ from utils.helpers import generate_unique_id
12
+ from config.schemas import BatchAnalysisResult
13
+ from features.detailed_result_maker import DetailedResultMaker
14
+
15
+
16
+ # Setup Logging
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class JSONReporter:
21
+ """
22
+ Professional JSON report generator
23
+
24
+ Features:
25
+ ---------
26
+ - Machine-readable structured format
27
+ - API-friendly output
28
+ - Complete data preservation
29
+ - Pretty-printed for readability
30
+ - Nested structure for complex data
31
+ """
32
+ def __init__(self):
33
+ """
34
+ Initialize JSON Reporter
35
+ """
36
+ self.detailed_maker = DetailedResultMaker()
37
+ logger.debug("JSONReporter initialized")
38
+
39
+
40
+ def export_batch(self, batch_result: BatchAnalysisResult, output_dir: Optional[Path] = None, include_detailed: bool = True) -> Path:
41
+ """
42
+ Export batch analysis as JSON
43
+
44
+ Arguments:
45
+ ----------
46
+ batch_result { BatchAnalysisResult } : Complete batch analysis result
47
+
48
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
49
+
50
+ include_detailed { bool } : Include detailed forensic data
51
+
52
+ Returns:
53
+ --------
54
+ { Path } : Path to generated JSON file
55
+ """
56
+ output_dir = output_dir or settings.REPORTS_DIR
57
+ report_id = generate_unique_id()
58
+ filename = f"batch_report_{report_id}.json"
59
+ output_path = output_dir / filename
60
+
61
+ output_dir.mkdir(parents = True, exist_ok = True)
62
+
63
+ logger.info(f"Generating batch JSON: {filename}")
64
+
65
+ try:
66
+ # Build JSON structure
67
+ data = self._build_batch_json(batch_result = batch_result,
68
+ include_detailed = include_detailed,
69
+ )
70
+
71
+ # Write to file
72
+ with open(output_path, 'w', encoding = 'utf-8') as f:
73
+ json.dump(obj = data,
74
+ fp = f,
75
+ indent = 4,
76
+ ensure_ascii = False,
77
+ default = str,
78
+ )
79
+
80
+ logger.info(f"Batch JSON generated: {output_path}")
81
+ return output_path
82
+
83
+ except Exception as e:
84
+ logger.error(f"Failed to generate batch JSON: {e}")
85
+ raise
86
+
87
+
88
+ def export_single(self, result: AnalysisResult, output_dir: Optional[Path] = None, include_detailed: bool = True) -> Path:
89
+ """
90
+ Export single image analysis as JSON
91
+
92
+ Arguments:
93
+ ----------
94
+ result { AnalysisResult } : Single image analysis result
95
+
96
+ output_dir { Path } : Output directory (defaults to settings.REPORTS_DIR)
97
+
98
+ include_detailed { bool } : Include detailed forensic data
99
+
100
+ Returns:
101
+ --------
102
+ { Path } : Path to generated JSON file
103
+ """
104
+ output_dir = output_dir or settings.REPORTS_DIR
105
+ report_id = generate_unique_id()
106
+ filename = f"single_report_{report_id}.json"
107
+ output_path = output_dir / filename
108
+
109
+ output_dir.mkdir(parents = True, exist_ok = True)
110
+
111
+ logger.info(f"Generating single image JSON: {filename}")
112
+
113
+ try:
114
+ # Build JSON structure
115
+ data = self._build_single_json(result = result,
116
+ include_detailed = include_detailed,
117
+ )
118
+
119
+ # Write to file
120
+ with open(output_path, 'w', encoding = 'utf-8') as f:
121
+ json.dump(obj = data,
122
+ fp = f,
123
+ indent = 4,
124
+ ensure_ascii = False,
125
+ default = str,
126
+ )
127
+
128
+ logger.info(f"Single image JSON generated: {output_path}")
129
+ return output_path
130
+
131
+ except Exception as e:
132
+ logger.error(f"Failed to generate single image JSON: {e}")
133
+ raise
134
+
135
+
136
+ def export_api_response(self, result: AnalysisResult) -> Dict:
137
+ """
138
+ Generate API-friendly JSON response (in-memory, no file)
139
+
140
+ Arguments:
141
+ ----------
142
+ result { AnalysisResult } : Analysis result
143
+
144
+ Returns:
145
+ --------
146
+ { dict } : API response dictionary
147
+ """
148
+ return {"success" : True,
149
+ "timestamp" : datetime.now().isoformat(),
150
+ "version" : settings.VERSION,
151
+ "data" : self._build_single_json(result = result,
152
+ include_detailed = False,
153
+ ),
154
+ }
155
+
156
+
157
+ def _build_batch_json(self, batch_result: BatchAnalysisResult, include_detailed: bool) -> Dict:
158
+ """
159
+ Build complete batch JSON structure
160
+ """
161
+ data = {"report_metadata" : self._build_metadata(report_type = "Batch Analysis",
162
+ timestamp = batch_result.timestamp,
163
+ ),
164
+ "batch_summary" : self._build_batch_summary(batch_result = batch_result),
165
+ "results" : [],
166
+ }
167
+
168
+ # Add each image result
169
+ for result in batch_result.results:
170
+ image_data = self._build_image_data(result = result,
171
+ include_detailed = include_detailed,
172
+ )
173
+ data["results"].append(image_data)
174
+
175
+ return data
176
+
177
+
178
+ def _build_single_json(self, result: AnalysisResult, include_detailed: bool) -> Dict:
179
+ """
180
+ Build single image JSON structure
181
+ """
182
+ data = {"report_metadata" : self._build_metadata(report_type = "Single Image Analysis",
183
+ timestamp = result.timestamp,
184
+ ),
185
+ "analysis" : self._build_image_data(result = result,
186
+ include_detailed = include_detailed,
187
+ ),
188
+ }
189
+
190
+ return data
191
+
192
+
193
+ def _build_metadata(self, report_type: str, timestamp: datetime) -> Dict:
194
+ """
195
+ Build report metadata section
196
+ """
197
+ return {"report_type" : report_type,
198
+ "generated_at" : timestamp.isoformat(),
199
+ "generator" : "AI Image Screener",
200
+ "version" : settings.VERSION,
201
+ "format_version" : "1.0",
202
+ }
203
+
204
+
205
+ def _build_batch_summary(self, batch_result: BatchAnalysisResult) -> Dict:
206
+ """
207
+ Build batch summary section
208
+ """
209
+ return {"total_images" : batch_result.total_images,
210
+ "processed" : batch_result.processed,
211
+ "failed" : batch_result.failed,
212
+ "success_rate" : batch_result.summary.get('success_rate', 0),
213
+ "statistics" : {"likely_authentic" : batch_result.summary.get('likely_authentic', 0),
214
+ "review_required" : batch_result.summary.get('review_required', 0),
215
+ "avg_score" : batch_result.summary.get('avg_score', 0.0),
216
+ "avg_confidence" : batch_result.summary.get('avg_confidence', 0),
217
+ "avg_proc_time" : batch_result.summary.get('avg_proc_time', 0.0),
218
+ },
219
+ "total_processing_time" : round(batch_result.total_processing_time, 2),
220
+ }
221
+
222
+
223
+ def _build_image_data(self, result: AnalysisResult, include_detailed: bool) -> Dict:
224
+ """
225
+ Build complete image data structure
226
+ """
227
+ image_data = {"filename" : result.filename,
228
+ "status" : result.status.value,
229
+ "overall" : {"score" : round(result.overall_score, 3),
230
+ "confidence" : result.confidence,
231
+ "interpretation" : self._interpret_score(score = result.overall_score),
232
+ },
233
+ "image_info" : {"size" : {"width" : result.image_size[0],
234
+ "height" : result.image_size[1],
235
+ },
236
+ "processing_time" : round(result.processing_time, 2),
237
+ "timestamp" : result.timestamp.isoformat(),
238
+ },
239
+ "signals" : self._build_signals_data(result = result),
240
+ }
241
+
242
+ # Add detailed forensics if requested
243
+ if include_detailed:
244
+ image_data["forensics"] = self._build_forensics_data(result = result)
245
+ image_data["recommendations"] = self._build_recommendations(result = result)
246
+
247
+ return image_data
248
+
249
+
250
+ def _build_signals_data(self, result: AnalysisResult) -> List[Dict]:
251
+ """
252
+ Build signals data structure
253
+ """
254
+ signals = list()
255
+
256
+ for signal in result.signals:
257
+ metric_result = result.metric_results.get(signal.metric_type)
258
+
259
+ signal_data = {"metric_name" : signal.name,
260
+ "metric_type" : signal.metric_type.value,
261
+ "score" : round(signal.score, 3),
262
+ "status" : signal.status.value,
263
+ "confidence" : round(metric_result.confidence, 3) if (metric_result and metric_result.confidence is not None) else None,
264
+ "explanation" : signal.explanation,
265
+ }
266
+
267
+ signals.append(signal_data)
268
+
269
+ return signals
270
+
271
+
272
+ def _build_forensics_data(self, result: AnalysisResult) -> Dict:
273
+ """
274
+ Build detailed forensics data structure
275
+ """
276
+ forensics = dict()
277
+
278
+ for metric_type, metric_result in result.metric_results.items():
279
+ metric_name = self.detailed_maker.metric_display_names.get(metric_type, metric_type.value)
280
+
281
+ forensics[metric_type.value] = {"display_name" : metric_name,
282
+ "score" : round(metric_result.score, 3),
283
+ "confidence" : round(metric_result.confidence, 3) if (metric_result and metric_result.confidence is not None) else None,
284
+ "details" : metric_result.details or {},
285
+ "key_findings" : self.detailed_maker.extract_key_findings(metric_type = metric_type,
286
+ metric_result = metric_result,
287
+ ),
288
+ }
289
+
290
+ return forensics
291
+
292
+
293
+ def _build_recommendations(self, result: AnalysisResult) -> Dict:
294
+ """
295
+ Build recommendations structure
296
+ """
297
+ score = result.overall_score
298
+
299
+ if (score >= 0.85):
300
+ return {"action" : "Immediate manual verification required",
301
+ "priority" : "HIGH",
302
+ "risk_level" : "CRITICAL",
303
+ "next_steps" : ["Forensic analysis", "Reverse image search", "Metadata inspection"],
304
+ "confidence" : "Very high likelihood of AI generation",
305
+ }
306
+
307
+ elif (score >= 0.70):
308
+ return {"action" : "Manual verification recommended",
309
+ "priority" : "MEDIUM",
310
+ "risk_level" : "HIGH",
311
+ "next_steps" : ["Visual inspection", "Compare with authentic samples"],
312
+ "confidence" : "High likelihood of AI generation",
313
+ }
314
+
315
+ elif (score >= 0.50):
316
+ return {"action" : "Optional review suggested",
317
+ "priority" : "LOW",
318
+ "risk_level" : "MEDIUM",
319
+ "next_steps" : ["Verify image source", "Check for inconsistencies"],
320
+ "confidence" : "Moderate indicators present",
321
+ }
322
+
323
+ else:
324
+ return {"action" : "No immediate action required",
325
+ "priority" : "NONE",
326
+ "risk_level" : "LOW",
327
+ "next_steps" : ["Proceed with normal workflow"],
328
+ "confidence" : "Low likelihood of AI generation",
329
+ }
330
+
331
+
332
+ def _interpret_score(self, score: float) -> str:
333
+ """
334
+ Interpret score for human readability
335
+ """
336
+ if (score >= 0.85):
337
+ return "Very high suspicion"
338
+
339
+ elif (score >= 0.70):
340
+ return "High suspicion"
341
+
342
+ elif (score >= 0.50):
343
+ return "Moderate suspicion"
344
+
345
+ elif (score >= 0.30):
346
+ return "Low suspicion"
347
+
348
+ else:
349
+ return "Very low suspicion"
reporter/pdf_reporter.py ADDED
@@ -0,0 +1,1050 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ from pathlib import Path
3
+ from typing import Optional, List, Dict, Any, Tuple
4
+ from datetime import datetime
5
+ from utils.logger import get_logger
6
+ from config.settings import settings
7
+ from reportlab.platypus import Table, Spacer, Paragraph, PageBreak
8
+ from reportlab.lib import colors
9
+ from reportlab.lib.pagesizes import LETTER
10
+ from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER
11
+ from reportlab.platypus import TableStyle
12
+ from config.schemas import AnalysisResult, BatchAnalysisResult
13
+ from utils.helpers import generate_unique_id
14
+ from config.constants import DetectionStatus, MetricType, SignalStatus
15
+ from reportlab.lib.styles import ParagraphStyle
16
+ from reportlab.platypus import SimpleDocTemplate
17
+ from reportlab.lib.styles import getSampleStyleSheet
18
+ from features.detailed_result_maker import DetailedResultMaker
19
+ from reportlab.lib.units import inch
20
+ from reportlab.pdfgen import canvas
21
+ from reportlab.lib.utils import simpleSplit
22
+
23
+
24
+ # Setup Logging
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ class PDFReporter:
29
+ """
30
+ Enhanced Enterprise PDF Report Generator
31
+
32
+ Design Philosophy:
33
+ ------------------
34
+ - Single page for 1 image (strict) with ALL details
35
+ - 2 pages for 2-5 images with comprehensive details
36
+ - Matrix-style pivot tables for 5+ images with full metric comparison
37
+ - Visual hierarchy with color coding
38
+ - No wasted whitespace, compact design
39
+ - Complete details from JSON including explanations and reasons
40
+ """
41
+
42
+ # Enhanced Color scheme
43
+ COLOR_PRIMARY = colors.HexColor('#2C3E50') # Dark blue-grey
44
+ COLOR_SECONDARY = colors.HexColor('#34495E') # Darker blue
45
+ COLOR_SUCCESS = colors.HexColor('#27AE60') # Green
46
+ COLOR_WARNING = colors.HexColor('#F39C12') # Orange
47
+ COLOR_DANGER = colors.HexColor('#E74C3C') # Red
48
+ COLOR_INFO = colors.HexColor('#3498DB') # Blue
49
+ COLOR_NEUTRAL = colors.HexColor('#95A5A6') # Grey
50
+ COLOR_HEADER_BG = colors.HexColor('#2C3E50') # Dark header
51
+ COLOR_ALT_ROW = colors.HexColor('#F8F9FA') # Very light grey
52
+ COLOR_ROW_HIGHLIGHT = colors.HexColor('#ECF0F1') # Light highlight
53
+
54
+ def __init__(self):
55
+ self.detailed_maker = DetailedResultMaker()
56
+ self.styles = self._build_styles()
57
+ logger.debug("Enhanced PDFReporter initialized")
58
+
59
+ def export_single(self, result: AnalysisResult, output_dir: Optional[Path] = None) -> Path:
60
+ """Export single image as comprehensive 1-page report"""
61
+ output_dir = output_dir or settings.REPORTS_DIR
62
+ output_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ report_id = generate_unique_id()
65
+ filename = f"single_analysis_{report_id}.pdf"
66
+ output_path = output_dir / filename
67
+
68
+ logger.info(f"Generating comprehensive single PDF: {filename}")
69
+
70
+ # Use LETTER with minimal margins
71
+ doc = SimpleDocTemplate(
72
+ str(output_path),
73
+ pagesize=LETTER,
74
+ rightMargin=20,
75
+ leftMargin=20,
76
+ topMargin=15,
77
+ bottomMargin=25
78
+ )
79
+
80
+ story = []
81
+ self._add_watermarked_header(story, "Single Image Analysis Report", result.timestamp)
82
+ self._add_comprehensive_single_image(story, result)
83
+ self._add_footer(story)
84
+
85
+ # Build with watermark
86
+ def add_watermark(canvas, doc):
87
+ canvas.saveState()
88
+ canvas.setFont('Helvetica', 40)
89
+ canvas.setFillColor(colors.HexColor('#F0F0F0'))
90
+ canvas.rotate(45)
91
+ canvas.drawString(200, 100, "AI IMAGE SCREENER")
92
+ canvas.restoreState()
93
+ # Add page number
94
+ canvas.setFont('Helvetica', 8)
95
+ canvas.setFillColor(colors.grey)
96
+ canvas.drawRightString(LETTER[0] - 20, 15, f"Page {doc.page}")
97
+
98
+ doc.build(story, onFirstPage=add_watermark, onLaterPages=add_watermark)
99
+ return output_path
100
+
101
+ def export_batch(self, batch_result: BatchAnalysisResult, output_dir: Optional[Path] = None) -> Path:
102
+ """Export batch with intelligent layout based on count"""
103
+ output_dir = output_dir or settings.REPORTS_DIR
104
+ output_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ report_id = generate_unique_id()
107
+ filename = f"batch_analysis_{report_id}.pdf"
108
+ output_path = output_dir / filename
109
+
110
+ num_images = len(batch_result.results)
111
+ logger.info(f"Generating batch PDF: {filename} ({num_images} images)")
112
+
113
+ doc = SimpleDocTemplate(
114
+ str(output_path),
115
+ pagesize=LETTER,
116
+ rightMargin=20,
117
+ leftMargin=20,
118
+ topMargin=15,
119
+ bottomMargin=25
120
+ )
121
+
122
+ story = []
123
+ self._add_watermarked_header(story, f"Batch Analysis Report ({num_images} Images)", batch_result.timestamp)
124
+
125
+ if num_images == 1:
126
+ # Single image (should use export_single but just in case)
127
+ self._add_comprehensive_single_image(story, batch_result.results[0])
128
+
129
+ elif 1 < num_images <= 5:
130
+ # 2-page comprehensive batch report
131
+ self._add_detailed_batch_summary(story, batch_result)
132
+ story.append(Spacer(1, 6))
133
+
134
+ # Add comprehensive details for each image
135
+ for idx, result in enumerate(batch_result.results, 1):
136
+ if idx > 1:
137
+ story.append(Spacer(1, 4))
138
+ self._add_comprehensive_image_mini(story, result, idx, num_images)
139
+
140
+ # Add page break after 2-3 images depending on content
141
+ if idx == 3 and num_images > 3:
142
+ story.append(PageBreak())
143
+ self._add_watermarked_header(story, f"Batch Analysis Report ({num_images} Images) - Continued", batch_result.timestamp)
144
+
145
+ else:
146
+ # 5+ images: Matrix/pivot style with comprehensive tables
147
+ self._add_batch_summary_matrix(story, batch_result)
148
+ story.append(Spacer(1, 6))
149
+
150
+ # Add comprehensive metric comparison tables
151
+ self._add_comprehensive_metric_tables(story, batch_result.results)
152
+
153
+ # Add page break if needed
154
+ if num_images > 8:
155
+ story.append(PageBreak())
156
+ self._add_watermarked_header(story, f"Batch Analysis Report ({num_images} Images) - Continued", batch_result.timestamp)
157
+ self._add_signal_summary_tables(story, batch_result.results)
158
+
159
+ self._add_footer(story)
160
+
161
+ # Build with watermark
162
+ def add_watermark(canvas, doc):
163
+ canvas.saveState()
164
+ canvas.setFont('Helvetica', 40)
165
+ canvas.setFillColor(colors.HexColor('#F0F0F0'))
166
+ canvas.rotate(45)
167
+ canvas.drawString(200, 100, "AI IMAGE SCREENER")
168
+ canvas.restoreState()
169
+ # Add page number
170
+ canvas.setFont('Helvetica', 8)
171
+ canvas.setFillColor(colors.grey)
172
+ canvas.drawRightString(LETTER[0] - 20, 15, f"Page {doc.page}")
173
+
174
+ doc.build(story, onFirstPage=add_watermark, onLaterPages=add_watermark)
175
+ return output_path
176
+
177
+ def _build_styles(self):
178
+ """Build comprehensive style definitions"""
179
+ styles = getSampleStyleSheet()
180
+
181
+ # Title styles
182
+ styles.add(ParagraphStyle(
183
+ name='ReportTitle',
184
+ fontSize=16,
185
+ textColor=self.COLOR_PRIMARY,
186
+ alignment=TA_CENTER,
187
+ spaceAfter=8,
188
+ fontName='Helvetica-Bold',
189
+ leading=20
190
+ ))
191
+
192
+ styles.add(ParagraphStyle(
193
+ name='SectionTitle',
194
+ fontSize=12,
195
+ textColor=self.COLOR_SECONDARY,
196
+ spaceBefore=10,
197
+ spaceAfter=6,
198
+ fontName='Helvetica-Bold',
199
+ leftIndent=0
200
+ ))
201
+
202
+ styles.add(ParagraphStyle(
203
+ name='SubSectionTitle',
204
+ fontSize=10,
205
+ textColor=self.COLOR_INFO,
206
+ spaceBefore=8,
207
+ spaceAfter=4,
208
+ fontName='Helvetica-Bold',
209
+ leftIndent=0
210
+ ))
211
+
212
+ # Text styles
213
+ styles.add(ParagraphStyle(
214
+ name='CustomBodyText',
215
+ fontSize=8,
216
+ leading=10,
217
+ spaceAfter=3,
218
+ alignment=TA_LEFT
219
+ ))
220
+
221
+ styles.add(ParagraphStyle(
222
+ name='SmallText',
223
+ fontSize=7,
224
+ leading=9,
225
+ spaceAfter=2
226
+ ))
227
+
228
+ styles.add(ParagraphStyle(
229
+ name='TableCell',
230
+ fontSize=7,
231
+ leading=9,
232
+ spaceAfter=0
233
+ ))
234
+
235
+ styles.add(ParagraphStyle(
236
+ name='TableCellBold',
237
+ fontSize=7,
238
+ leading=9,
239
+ spaceAfter=0,
240
+ fontName='Helvetica-Bold'
241
+ ))
242
+
243
+ styles.add(ParagraphStyle(
244
+ name='TableCellSmall',
245
+ fontSize=6.5,
246
+ leading=8,
247
+ spaceAfter=0
248
+ ))
249
+
250
+ styles.add(ParagraphStyle(
251
+ name='Timestamp',
252
+ fontSize=7,
253
+ textColor=colors.grey,
254
+ alignment=TA_RIGHT
255
+ ))
256
+
257
+ styles.add(ParagraphStyle(
258
+ name='CustomBullet',
259
+ fontSize=7,
260
+ leading=9,
261
+ leftIndent=10,
262
+ spaceAfter=1
263
+ ))
264
+
265
+ styles.add(ParagraphStyle(
266
+ name='WarningText',
267
+ fontSize=8,
268
+ textColor=self.COLOR_WARNING,
269
+ leading=10,
270
+ spaceAfter=3
271
+ ))
272
+
273
+ styles.add(ParagraphStyle(
274
+ name='AlertText',
275
+ fontSize=8,
276
+ textColor=self.COLOR_DANGER,
277
+ leading=10,
278
+ spaceAfter=3,
279
+ fontName='Helvetica-Bold'
280
+ ))
281
+
282
+ return styles
283
+
284
+ def _add_watermarked_header(self, story, title: str, timestamp: datetime):
285
+ """Header with centered title and timestamp"""
286
+ # Title centered
287
+ story.append(Paragraph("🔍 AI Image Screener", self.styles['ReportTitle']))
288
+
289
+ # Subtitle with timestamp
290
+ data = [[
291
+ Paragraph(title, self.styles['SubSectionTitle']),
292
+ Paragraph(f"Generated: {timestamp.strftime('%Y-%m-%d %H:%M:%S')}", self.styles['Timestamp'])
293
+ ]]
294
+
295
+ table = Table(data, colWidths=[400, 150])
296
+ table.setStyle(TableStyle([
297
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
298
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
299
+ ]))
300
+ story.append(table)
301
+
302
+ story.append(Spacer(1, 4))
303
+
304
+ def _add_comprehensive_single_image(self, story, result: AnalysisResult):
305
+ """Comprehensive single page layout with ALL details"""
306
+
307
+ # 1. Basic Information Table
308
+ story.append(Paragraph("Basic Information", self.styles['SectionTitle']))
309
+
310
+ overview_data = [
311
+ [
312
+ Paragraph("<b>Filename:</b>", self.styles['TableCellBold']),
313
+ Paragraph(result.filename, self.styles['TableCell']),
314
+ Paragraph("<b>Image Size:</b>", self.styles['TableCellBold']),
315
+ Paragraph(f"{result.image_size[0]} × {result.image_size[1]} pixels", self.styles['TableCell'])
316
+ ],
317
+ [
318
+ Paragraph("<b>Overall Status:</b>", self.styles['TableCellBold']),
319
+ Paragraph(self._get_status_html(result.status.value), self.styles['TableCell']),
320
+ Paragraph("<b>Overall Score:</b>", self.styles['TableCellBold']),
321
+ Paragraph(f"<font color='{self._get_score_color(result.overall_score)}'><b>{result.overall_score:.3f}</b></font>", self.styles['TableCell'])
322
+ ],
323
+ [
324
+ Paragraph("<b>Confidence:</b>", self.styles['TableCellBold']),
325
+ Paragraph(f"{result.confidence}%", self.styles['TableCell']),
326
+ Paragraph("<b>Processing Time:</b>", self.styles['TableCellBold']),
327
+ Paragraph(f"{result.processing_time:.3f} seconds", self.styles['TableCell'])
328
+ ],
329
+ [
330
+ Paragraph("<b>Analysis Timestamp:</b>", self.styles['TableCellBold']),
331
+ Paragraph(result.timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.styles['TableCell']),
332
+ Paragraph("", self.styles['TableCell']),
333
+ Paragraph("", self.styles['TableCell'])
334
+ ]
335
+ ]
336
+
337
+ table = Table(overview_data, colWidths=[80, 200, 80, 200])
338
+ table.setStyle(TableStyle([
339
+ ('BACKGROUND', (0, 0), (-1, -1), self.COLOR_ALT_ROW),
340
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
341
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
342
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
343
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
344
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
345
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3)
346
+ ]))
347
+ story.append(table)
348
+ story.append(Spacer(1, 8))
349
+
350
+ # 2. Detection Signals (Comprehensive)
351
+ story.append(Paragraph("Detection Signals Analysis", self.styles['SectionTitle']))
352
+
353
+ signal_data = [[
354
+ Paragraph("<font color='white'><b>Metric</b></font>", self.styles['TableCellBold']),
355
+ Paragraph("<font color='white'><b>Score</b></font>", self.styles['TableCellBold']),
356
+ Paragraph("<font color='white'><b>Status</b></font>", self.styles['TableCellBold']),
357
+ Paragraph("<font color='white'><b>Confidence</b></font>", self.styles['TableCellBold']),
358
+ Paragraph("<font color='white'><b>Explanation</b></font>", self.styles['TableCellBold'])
359
+ ]]
360
+
361
+ for signal in result.signals:
362
+ metric_result = result.metric_results.get(signal.metric_type)
363
+ confidence = metric_result.confidence if metric_result and metric_result.confidence is not None else "N/A"
364
+
365
+ signal_data.append([
366
+ Paragraph(signal.name, self.styles['TableCell']),
367
+ Paragraph(f"<b>{signal.score:.3f}</b>", self.styles['TableCell']),
368
+ Paragraph(self._get_signal_status_html(signal.status.value), self.styles['TableCell']),
369
+ Paragraph(f"{confidence:.3f}" if isinstance(confidence, (int, float)) else str(confidence), self.styles['TableCell']),
370
+ Paragraph(signal.explanation, self.styles['TableCellSmall'])
371
+ ])
372
+
373
+ table = Table(signal_data, colWidths=[90, 45, 60, 55, 250])
374
+ table.setStyle(TableStyle([
375
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_HEADER_BG),
376
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
377
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
378
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
379
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
380
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
381
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
382
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
383
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3)
384
+ ]))
385
+ story.append(table)
386
+ story.append(Spacer(1, 8))
387
+
388
+ # 3. Detailed Forensic Analysis (All metrics with full details)
389
+ story.append(Paragraph("Detailed Forensic Analysis", self.styles['SectionTitle']))
390
+
391
+ # Process each metric type
392
+ metric_order = ['gradient', 'frequency', 'noise', 'texture', 'color']
393
+ metric_display_names = {
394
+ 'gradient': 'Gradient-Field PCA',
395
+ 'frequency': 'Frequency Analysis (FFT)',
396
+ 'noise': 'Noise Pattern Analysis',
397
+ 'texture': 'Texture Statistics',
398
+ 'color': 'Color Distribution'
399
+ }
400
+
401
+ for metric_key in metric_order:
402
+ if metric_key not in result.metric_results:
403
+ continue
404
+
405
+ metric_result = result.metric_results[metric_key]
406
+ details = metric_result.details or {}
407
+
408
+ story.append(Paragraph(metric_display_names.get(metric_key, metric_key), self.styles['SubSectionTitle']))
409
+
410
+ # Create metric summary row
411
+ summary_data = [[
412
+ Paragraph("<b>Metric Score:</b>", self.styles['TableCellBold']),
413
+ Paragraph(f"<b>{metric_result.score:.3f}</b>", self.styles['TableCell']),
414
+ Paragraph("<b>Confidence:</b>", self.styles['TableCellBold']),
415
+ Paragraph(f"{metric_result.confidence:.3f}" if metric_result.confidence is not None else "N/A", self.styles['TableCell'])
416
+ ]]
417
+
418
+ summary_table = Table(summary_data, colWidths=[70, 50, 70, 50])
419
+ summary_table.setStyle(TableStyle([
420
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_ALT_ROW),
421
+ ('GRID', (0, 0), (-1, 0), 0.5, colors.grey),
422
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
423
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
424
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
425
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
426
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
427
+ ]))
428
+ story.append(summary_table)
429
+ story.append(Spacer(1, 4))
430
+
431
+ # Create detailed table for this metric
432
+ if details:
433
+ # Get appropriate headers and data for this metric
434
+ headers, rows = self._get_metric_details_table(metric_key, details)
435
+
436
+ if headers and rows:
437
+ table_data = [headers] + rows
438
+ col_widths = self._get_metric_column_widths(metric_key)
439
+
440
+ detail_table = Table(table_data, colWidths=col_widths)
441
+ detail_table.setStyle(TableStyle([
442
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_SECONDARY),
443
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
444
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
445
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
446
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
447
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
448
+ ('FONTSIZE', (0, 0), (-1, 0), 7),
449
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
450
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
451
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
452
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
453
+ ]))
454
+ story.append(detail_table)
455
+ story.append(Spacer(1, 6))
456
+ else:
457
+ # Handle nested dictionaries
458
+ bullet_points = self._format_details_as_bullets(details)
459
+ for bullet in bullet_points:
460
+ story.append(Paragraph(bullet, self.styles['Bullet']))
461
+ story.append(Spacer(1, 4))
462
+ else:
463
+ story.append(Paragraph("No detailed forensic data available.", self.styles['SmallText']))
464
+ story.append(Spacer(1, 4))
465
+
466
+ # 4. Recommendation
467
+ story.append(Paragraph("Recommendation", self.styles['SectionTitle']))
468
+
469
+ if result.overall_score >= 0.85:
470
+ rec_color = self.COLOR_DANGER
471
+ rec_text = "⚠️ <b>CRITICAL</b>: Immediate manual verification required"
472
+ next_steps = ["Forensic analysis", "Reverse image search", "Metadata inspection", "Expert review"]
473
+ elif result.overall_score >= 0.70:
474
+ rec_color = self.COLOR_WARNING
475
+ rec_text = "⚠️ <b>HIGH RISK</b>: Manual verification recommended"
476
+ next_steps = ["Visual inspection", "Compare with authentic samples", "Check source provenance"]
477
+ elif result.overall_score >= 0.50:
478
+ rec_color = colors.HexColor('#F1C40F')
479
+ rec_text = "⚠️ <b>MEDIUM RISK</b>: Optional review suggested"
480
+ next_steps = ["May be edited photo", "Verify image source", "Check for inconsistencies"]
481
+ else:
482
+ rec_color = self.COLOR_SUCCESS
483
+ rec_text = "✅ <b>LOW RISK</b>: No immediate action required"
484
+ next_steps = ["Proceed with normal workflow"]
485
+
486
+ rec_data = [[Paragraph(rec_text, self.styles['AlertText'])]]
487
+ rec_table = Table(rec_data, colWidths=[560])
488
+ rec_table.setStyle(TableStyle([
489
+ ('BACKGROUND', (0, 0), (-1, -1), rec_color),
490
+ ('TEXTCOLOR', (0, 0), (-1, -1), colors.white),
491
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
492
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
493
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
494
+ ('RIGHTPADDING', (0, 0), (-1, -1), 8),
495
+ ('TOPPADDING', (0, 0), (-1, -1), 6),
496
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 6),
497
+ ('ROUNDEDCORNERS', [10, 10, 10, 10])
498
+ ]))
499
+ story.append(rec_table)
500
+
501
+ # Next steps as bullet points
502
+ story.append(Spacer(1, 4))
503
+ for step in next_steps:
504
+ story.append(Paragraph(f"• {step}", self.styles['Bullet']))
505
+
506
+ def _add_detailed_batch_summary(self, story, batch_result: BatchAnalysisResult):
507
+ """Detailed batch summary for 2-5 images"""
508
+ summary = batch_result.summary
509
+
510
+ story.append(Paragraph("Batch Summary", self.styles['SectionTitle']))
511
+
512
+ summary_data = [
513
+ [
514
+ Paragraph("<b>Total Images:</b>", self.styles['TableCellBold']),
515
+ Paragraph(str(batch_result.total_images), self.styles['TableCell']),
516
+ Paragraph("<b>Processed:</b>", self.styles['TableCellBold']),
517
+ Paragraph(f"<font color='green'>{batch_result.processed}</font>", self.styles['TableCell']),
518
+ Paragraph("<b>Failed:</b>", self.styles['TableCellBold']),
519
+ Paragraph(f"<font color='red'>{batch_result.failed}</font>" if batch_result.failed > 0 else str(batch_result.failed), self.styles['TableCell'])
520
+ ],
521
+ [
522
+ Paragraph("<b>Authentic:</b>", self.styles['TableCellBold']),
523
+ Paragraph(f"<font color='green'>{summary.get('likely_authentic', 0)}</font>", self.styles['TableCell']),
524
+ Paragraph("<b>Review Required:</b>", self.styles['TableCellBold']),
525
+ Paragraph(f"<font color='orange'>{summary.get('review_required', 0)}</font>", self.styles['TableCell']),
526
+ Paragraph("<b>Success Rate:</b>", self.styles['TableCellBold']),
527
+ Paragraph(f"{summary.get('success_rate', 0)}%", self.styles['TableCell'])
528
+ ],
529
+ [
530
+ Paragraph("<b>Average Score:</b>", self.styles['TableCellBold']),
531
+ Paragraph(f"{summary.get('avg_score', 0):.3f}", self.styles['TableCell']),
532
+ Paragraph("<b>Average Confidence:</b>", self.styles['TableCellBold']),
533
+ Paragraph(f"{summary.get('avg_confidence', 0)}%", self.styles['TableCell']),
534
+ Paragraph("<b>Avg Processing Time:</b>", self.styles['TableCellBold']),
535
+ Paragraph(f"{summary.get('avg_proc_time', 0):.2f}s", self.styles['TableCell'])
536
+ ],
537
+ [
538
+ Paragraph("<b>Total Processing Time:</b>", self.styles['TableCellBold']),
539
+ Paragraph(f"{batch_result.total_processing_time:.2f}s", self.styles['TableCell']),
540
+ Paragraph("", self.styles['TableCell']),
541
+ Paragraph("", self.styles['TableCell']),
542
+ Paragraph("", self.styles['TableCell']),
543
+ Paragraph("", self.styles['TableCell'])
544
+ ]
545
+ ]
546
+
547
+ table = Table(summary_data, colWidths=[80, 60, 80, 60, 80, 60])
548
+ table.setStyle(TableStyle([
549
+ ('BACKGROUND', (0, 0), (-1, -1), self.COLOR_ALT_ROW),
550
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
551
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
552
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
553
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
554
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
555
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3)
556
+ ]))
557
+ story.append(table)
558
+
559
+ def _add_comprehensive_image_mini(self, story, result: AnalysisResult, idx: int, total: int):
560
+ """Comprehensive mini section for 2-5 images batch"""
561
+ story.append(Paragraph(f"Image {idx}/{total}: {result.filename}", self.styles['SubSectionTitle']))
562
+
563
+ # Basic info table
564
+ info_data = [
565
+ [
566
+ Paragraph("<b>Status:</b>", self.styles['TableCellBold']),
567
+ Paragraph(self._get_status_html(result.status.value), self.styles['TableCell']),
568
+ Paragraph("<b>Score:</b>", self.styles['TableCellBold']),
569
+ Paragraph(f"{result.overall_score:.3f}", self.styles['TableCell']),
570
+ Paragraph("<b>Confidence:</b>", self.styles['TableCellBold']),
571
+ Paragraph(f"{result.confidence}%", self.styles['TableCell'])
572
+ ],
573
+ [
574
+ Paragraph("<b>Size:</b>", self.styles['TableCellBold']),
575
+ Paragraph(f"{result.image_size[0]}×{result.image_size[1]}", self.styles['TableCell']),
576
+ Paragraph("<b>Time:</b>", self.styles['TableCellBold']),
577
+ Paragraph(f"{result.processing_time:.3f}s", self.styles['TableCell']),
578
+ Paragraph("", self.styles['TableCell']),
579
+ Paragraph("", self.styles['TableCell'])
580
+ ]
581
+ ]
582
+
583
+ info_table = Table(info_data, colWidths=[50, 100, 50, 60, 60, 60])
584
+ info_table.setStyle(TableStyle([
585
+ ('BACKGROUND', (0, 0), (-1, -1), self.COLOR_ALT_ROW),
586
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
587
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
588
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
589
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
590
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
591
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
592
+ ]))
593
+ story.append(info_table)
594
+ story.append(Spacer(1, 4))
595
+
596
+ # Signals summary
597
+ signal_data = [[
598
+ Paragraph("<font color='white'><b>Metric</b></font>", self.styles['TableCellBold']),
599
+ Paragraph("<font color='white'><b>Score</b></font>", self.styles['TableCellBold']),
600
+ Paragraph("<font color='white'><b>Status</b></font>", self.styles['TableCellBold']),
601
+ Paragraph("<font color='white'><b>Explanation</b></font>", self.styles['TableCellBold'])
602
+ ]]
603
+
604
+ for signal in result.signals:
605
+ signal_data.append([
606
+ Paragraph(signal.name[:20], self.styles['TableCellSmall']),
607
+ Paragraph(f"{signal.score:.3f}", self.styles['TableCellSmall']),
608
+ Paragraph(self._get_signal_status_html(signal.status.value), self.styles['TableCellSmall']),
609
+ Paragraph(signal.explanation[:60] + "..." if len(signal.explanation) > 60 else signal.explanation, self.styles['TableCellSmall'])
610
+ ])
611
+
612
+ sig_table = Table(signal_data, colWidths=[90, 45, 60, 265])
613
+ sig_table.setStyle(TableStyle([
614
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_HEADER_BG),
615
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
616
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
617
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
618
+ ('VALIGN', (0, 0), (-1, -1), 'TOP'),
619
+ ('FONTSIZE', (0, 0), (-1, -1), 6.5),
620
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
621
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
622
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
623
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
624
+ ]))
625
+ story.append(sig_table)
626
+ story.append(Spacer(1, 6))
627
+
628
+ def _add_batch_summary_matrix(self, story, batch_result: BatchAnalysisResult):
629
+ """Matrix-style summary for 5+ images"""
630
+ story.append(Paragraph("Batch Overview Matrix", self.styles['SectionTitle']))
631
+
632
+ # Header
633
+ header = [
634
+ Paragraph("<font color='white'><b>#</b></font>", self.styles['TableCellBold']),
635
+ Paragraph("<font color='white'><b>Filename</b></font>", self.styles['TableCellBold']),
636
+ Paragraph("<font color='white'><b>Image Size</b></font>", self.styles['TableCellBold']),
637
+ Paragraph("<font color='white'><b>Score</b></font>", self.styles['TableCellBold']),
638
+ Paragraph("<font color='white'><b>Status</b></font>", self.styles['TableCellBold']),
639
+ Paragraph("<font color='white'><b>Top Signal</b></font>", self.styles['TableCellBold']),
640
+ Paragraph("<font color='white'><b>Confidence</b></font>", self.styles['TableCellBold']),
641
+ Paragraph("<font color='white'><b>Time(s)</b></font>", self.styles['TableCellBold'])
642
+ ]
643
+
644
+ data = [header]
645
+
646
+ for idx, result in enumerate(batch_result.results, 1):
647
+ top_signal = max(result.signals, key=lambda s: s.score) if result.signals else None
648
+
649
+ data.append([
650
+ Paragraph(str(idx), self.styles['TableCell']),
651
+ Paragraph(result.filename, self.styles['TableCellSmall']),
652
+ Paragraph(f"{result.image_size[0]}×{result.image_size[1]}", self.styles['TableCell']),
653
+ Paragraph(f"{result.overall_score:.3f}", self.styles['TableCell']),
654
+ Paragraph(self._get_status_html(result.status.value), self.styles['TableCell']),
655
+ Paragraph(f"{top_signal.name}: {top_signal.score:.2f}" if top_signal else "N/A", self.styles['TableCellSmall']),
656
+ Paragraph(f"{result.confidence}%", self.styles['TableCell']),
657
+ Paragraph(f"{result.processing_time:.2f}", self.styles['TableCell'])
658
+ ])
659
+
660
+ table = Table(data, colWidths=[25, 150, 70, 50, 70, 110, 55, 40])
661
+ table.setStyle(TableStyle([
662
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_HEADER_BG),
663
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
664
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
665
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
666
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
667
+ ('ALIGN', (0, 0), (0, -1), 'CENTER'),
668
+ ('ALIGN', (2, 1), (7, -1), 'CENTER'),
669
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
670
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
671
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
672
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
673
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
674
+ ]))
675
+ story.append(table)
676
+
677
+ def _add_comprehensive_metric_tables(self, story, results: List[AnalysisResult]):
678
+ """Comprehensive metric comparison tables for 5+ images"""
679
+ metric_configs = {
680
+ 'gradient': {
681
+ 'title': 'Gradient-Field PCA Analysis',
682
+ 'headers': ['Filename', 'Eigenvalue Ratio', 'Vectors Sampled', 'Original Pixels', 'Filtered Vectors', 'Threshold', 'Score', 'Confidence'],
683
+ 'extractors': [
684
+ lambda d: d.get('eigenvalue_ratio', 'N/A'),
685
+ lambda d: d.get('gradient_vectors_sampled', 'N/A'),
686
+ lambda d: d.get('original_pixels', 'N/A'),
687
+ lambda d: d.get('filtered_vectors', 'N/A'),
688
+ lambda d: d.get('threshold', 'N/A')
689
+ ]
690
+ },
691
+ 'frequency': {
692
+ 'title': 'Frequency Analysis (FFT)',
693
+ 'headers': ['Filename', 'HF Ratio', 'Roughness', 'Low Freq Energy', 'High Freq Energy', 'HF Anomaly', 'Spectral Deviation', 'Score', 'Confidence'],
694
+ 'extractors': [
695
+ lambda d: d.get('hf_ratio', 'N/A'),
696
+ lambda d: d.get('roughness', 'N/A'),
697
+ lambda d: d.get('low_freq_energy', 'N/A'),
698
+ lambda d: d.get('high_freq_energy', 'N/A'),
699
+ lambda d: d.get('hf_anomaly', 'N/A'),
700
+ lambda d: d.get('spectral_deviation', 'N/A')
701
+ ]
702
+ },
703
+ 'noise': {
704
+ 'title': 'Noise Pattern Analysis',
705
+ 'headers': ['Filename', 'Mean Noise', 'CV', 'Patches Total', 'Patches Valid', 'Noise Level Anomaly', 'Reason', 'Score', 'Confidence'],
706
+ 'extractors': [
707
+ lambda d: d.get('mean_noise', 'N/A'),
708
+ lambda d: d.get('cv', 'N/A'),
709
+ lambda d: d.get('patches_total', 'N/A'),
710
+ lambda d: d.get('patches_valid', 'N/A'),
711
+ lambda d: d.get('noise_level_anomaly', 'N/A'),
712
+ lambda d: d.get('reason', 'N/A')
713
+ ]
714
+ },
715
+ 'texture': {
716
+ 'title': 'Texture Statistics',
717
+ 'headers': ['Filename', 'Smooth Ratio', 'Contrast Mean', 'Entropy Mean', 'Patches Used', 'Edge Density', 'Contrast CV', 'Score', 'Confidence'],
718
+ 'extractors': [
719
+ lambda d: d.get('smooth_ratio', 'N/A'),
720
+ lambda d: d.get('contrast_mean', 'N/A'),
721
+ lambda d: d.get('entropy_mean', 'N/A'),
722
+ lambda d: d.get('patches_used', 'N/A'),
723
+ lambda d: d.get('edge_density_mean', 'N/A'),
724
+ lambda d: d.get('contrast_cv', 'N/A')
725
+ ]
726
+ },
727
+ 'color': {
728
+ 'title': 'Color Distribution',
729
+ 'headers': ['Filename', 'Mean Saturation', 'High Sat Ratio', 'Top3 Concentration', 'Gap Ratio', 'Histogram Roughness', 'Reason', 'Score', 'Confidence'],
730
+ 'extractors': [
731
+ lambda d: self._extract_color_detail(d, 'mean_saturation'),
732
+ lambda d: self._extract_color_detail(d, 'high_sat_ratio'),
733
+ lambda d: self._extract_color_detail(d, 'top3_concentration'),
734
+ lambda d: self._extract_color_detail(d, 'gap_ratio'),
735
+ lambda d: self._extract_color_detail(d, 'roughness_mean'),
736
+ lambda d: self._extract_color_reason(d)
737
+ ]
738
+ }
739
+ }
740
+
741
+ for metric_key, config in metric_configs.items():
742
+ story.append(Spacer(1, 8))
743
+ story.append(Paragraph(config['title'], self.styles['SectionTitle']))
744
+
745
+ # Build header row
746
+ header = [Paragraph(f"<font color='white'><b>{h}</b></font>", self.styles['TableCellBold']) for h in config['headers']]
747
+ data = [header]
748
+
749
+ # Build data rows
750
+ for result in results:
751
+ metric_result = result.metric_results.get(metric_key)
752
+ if not metric_result:
753
+ continue
754
+
755
+ details = metric_result.details or {}
756
+ row = [Paragraph(result.filename, self.styles['TableCellSmall'])]
757
+
758
+ # Extract details using the extractor functions
759
+ for extractor in config['extractors']:
760
+ value = extractor(details)
761
+ if isinstance(value, float):
762
+ row.append(Paragraph(f"{value:.3f}", self.styles['TableCell']))
763
+ else:
764
+ row.append(Paragraph(str(value), self.styles['TableCell']))
765
+
766
+ # Add score and confidence
767
+ row.append(Paragraph(f"{metric_result.score:.3f}", self.styles['TableCell']))
768
+ row.append(Paragraph(f"{metric_result.confidence:.3f}" if metric_result.confidence else "N/A", self.styles['TableCell']))
769
+
770
+ data.append(row)
771
+
772
+ # Calculate column widths
773
+ col_widths = [150] + [60] * (len(config['headers']) - 3) + [50, 50]
774
+
775
+ table = Table(data, colWidths=col_widths)
776
+ table.setStyle(TableStyle([
777
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_HEADER_BG),
778
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
779
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
780
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
781
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
782
+ ('ALIGN', (1, 1), (-3, -1), 'CENTER'),
783
+ ('FONTSIZE', (0, 0), (-1, -1), 6.5),
784
+ ('LEFTPADDING', (0, 0), (-1, -1), 3),
785
+ ('RIGHTPADDING', (0, 0), (-1, -1), 3),
786
+ ('TOPPADDING', (0, 0), (-1, -1), 2),
787
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 2)
788
+ ]))
789
+ story.append(table)
790
+
791
+ def _add_signal_summary_tables(self, story, results: List[AnalysisResult]):
792
+ """Signal summary tables for large batches"""
793
+ story.append(Paragraph("Signal Status Summary", self.styles['SectionTitle']))
794
+
795
+ # Count signals by status
796
+ status_counts = {'flagged': 0, 'warning': 0, 'passed': 0}
797
+ metric_counts = {}
798
+
799
+ for result in results:
800
+ for signal in result.signals:
801
+ status = signal.status.value
802
+ status_counts[status] = status_counts.get(status, 0) + 1
803
+
804
+ metric_type = signal.metric_type.value
805
+ metric_counts[metric_type] = metric_counts.get(metric_type, 0) + 1
806
+
807
+ # Status summary table
808
+ story.append(Paragraph("Signal Status Distribution", self.styles['SubSectionTitle']))
809
+
810
+ status_data = [[
811
+ Paragraph("<font color='white'><b>Status</b></font>", self.styles['TableCellBold']),
812
+ Paragraph("<font color='white'><b>Count</b></font>", self.styles['TableCellBold']),
813
+ Paragraph("<font color='white'><b>Percentage</b></font>", self.styles['TableCellBold'])
814
+ ]]
815
+
816
+ total_signals = sum(status_counts.values())
817
+ for status, count in status_counts.items():
818
+ percentage = (count / total_signals * 100) if total_signals > 0 else 0
819
+ status_data.append([
820
+ Paragraph(self._get_signal_status_html(status), self.styles['TableCell']),
821
+ Paragraph(str(count), self.styles['TableCell']),
822
+ Paragraph(f"{percentage:.1f}%", self.styles['TableCell'])
823
+ ])
824
+
825
+ status_table = Table(status_data, colWidths=[100, 80, 80])
826
+ status_table.setStyle(TableStyle([
827
+ ('BACKGROUND', (0, 0), (-1, 0), self.COLOR_HEADER_BG),
828
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
829
+ ('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
830
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, self.COLOR_ALT_ROW]),
831
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
832
+ ('ALIGN', (1, 1), (-1, -1), 'CENTER'),
833
+ ('LEFTPADDING', (0, 0), (-1, -1), 4),
834
+ ('RIGHTPADDING', (0, 0), (-1, -1), 4),
835
+ ('TOPPADDING', (0, 0), (-1, -1), 3),
836
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 3)
837
+ ]))
838
+ story.append(status_table)
839
+
840
+ def _get_metric_details_table(self, metric_key: str, details: dict) -> Tuple[List, List]:
841
+ """Get appropriate headers and data rows for a metric details table"""
842
+ headers_map = {
843
+ 'gradient': ['Parameter', 'Value', 'Description'],
844
+ 'frequency': ['Parameter', 'Value', 'Description'],
845
+ 'noise': ['Parameter', 'Value', 'Description'],
846
+ 'texture': ['Parameter', 'Value', 'Description'],
847
+ 'color': ['Parameter', 'Value', 'Description']
848
+ }
849
+
850
+ # Define what to show for each metric
851
+ metric_parameters = {
852
+ 'gradient': [
853
+ ('eigenvalue_ratio', 'Eigenvalue Ratio', 'Ratio of eigenvalues indicating gradient alignment'),
854
+ ('gradient_vectors_sampled', 'Vectors Sampled', 'Number of gradient vectors analyzed'),
855
+ ('original_pixels', 'Original Pixels', 'Total pixels in the image'),
856
+ ('filtered_vectors', 'Filtered Vectors', 'Vectors after filtering'),
857
+ ('threshold', 'Threshold', 'Detection threshold value')
858
+ ],
859
+ 'frequency': [
860
+ ('hf_ratio', 'HF Ratio', 'High-frequency energy ratio'),
861
+ ('roughness', 'Roughness', 'Spectral roughness measure'),
862
+ ('low_freq_energy', 'Low Freq Energy', 'Low frequency energy'),
863
+ ('high_freq_energy', 'High Freq Energy', 'High frequency energy'),
864
+ ('hf_anomaly', 'HF Anomaly', 'High-frequency anomaly score'),
865
+ ('spectral_deviation', 'Spectral Deviation', 'Deviation from normal spectrum')
866
+ ],
867
+ 'noise': [
868
+ ('mean_noise', 'Mean Noise', 'Average noise level'),
869
+ ('cv', 'CV', 'Coefficient of variation'),
870
+ ('patches_total', 'Patches Total', 'Total patches analyzed'),
871
+ ('patches_valid', 'Patches Valid', 'Valid patches for analysis'),
872
+ ('noise_level_anomaly', 'Noise Anomaly', 'Noise level anomaly score'),
873
+ ('reason', 'Reason', 'Analysis reason or limitation')
874
+ ],
875
+ 'texture': [
876
+ ('smooth_ratio', 'Smooth Ratio', 'Ratio of smooth texture patches'),
877
+ ('contrast_mean', 'Contrast Mean', 'Average texture contrast'),
878
+ ('entropy_mean', 'Entropy Mean', 'Average texture entropy'),
879
+ ('patches_used', 'Patches Used', 'Texture patches used in analysis'),
880
+ ('edge_density_mean', 'Edge Density', 'Average edge density'),
881
+ ('contrast_cv', 'Contrast CV', 'Contrast coefficient of variation')
882
+ ],
883
+ 'color': [
884
+ ('saturation_stats', 'Saturation Stats', 'Color saturation statistics'),
885
+ ('histogram_stats', 'Histogram Stats', 'Color histogram statistics'),
886
+ ('hue_stats', 'Hue Stats', 'Hue distribution statistics')
887
+ ]
888
+ }
889
+
890
+ headers = [Paragraph(f"<font color='white'><b>{h}</b></font>", self.styles['TableCellBold']) for h in headers_map.get(metric_key, ['Parameter', 'Value'])]
891
+ rows = []
892
+
893
+ params = metric_parameters.get(metric_key, [])
894
+ for param_key, display_name, description in params:
895
+ if param_key in details:
896
+ value = details[param_key]
897
+ if isinstance(value, dict):
898
+ # Handle nested dictionaries
899
+ for sub_key, sub_value in value.items():
900
+ if sub_key != 'reason' or sub_value:
901
+ rows.append([
902
+ Paragraph(f" {sub_key}", self.styles['TableCellSmall']),
903
+ Paragraph(str(sub_value), self.styles['TableCellSmall']),
904
+ Paragraph("", self.styles['TableCellSmall'])
905
+ ])
906
+ else:
907
+ rows.append([
908
+ Paragraph(display_name, self.styles['TableCell']),
909
+ Paragraph(str(value), self.styles['TableCell']),
910
+ Paragraph(description, self.styles['TableCellSmall'])
911
+ ])
912
+ elif param_key == 'saturation_stats' and 'saturation_stats' in details:
913
+ sat_stats = details['saturation_stats']
914
+ if isinstance(sat_stats, dict):
915
+ if 'reason' in sat_stats:
916
+ rows.append([
917
+ Paragraph('Saturation Analysis', self.styles['TableCell']),
918
+ Paragraph(sat_stats['reason'], self.styles['TableCell']),
919
+ Paragraph('Reason for saturation analysis result', self.styles['TableCellSmall'])
920
+ ])
921
+ else:
922
+ for stat_key, stat_value in sat_stats.items():
923
+ rows.append([
924
+ Paragraph(f" {stat_key}", self.styles['TableCellSmall']),
925
+ Paragraph(str(stat_value), self.styles['TableCellSmall']),
926
+ Paragraph("", self.styles['TableCellSmall'])
927
+ ])
928
+
929
+ return headers, rows
930
+
931
+ def _get_metric_column_widths(self, metric_key: str) -> List:
932
+ """Get appropriate column widths for metric tables"""
933
+ width_map = {
934
+ 'gradient': [120, 80, 280],
935
+ 'frequency': [120, 80, 280],
936
+ 'noise': [120, 80, 280],
937
+ 'texture': [120, 80, 280],
938
+ 'color': [120, 80, 280]
939
+ }
940
+ return width_map.get(metric_key, [120, 80, 280])
941
+
942
+ def _format_details_as_bullets(self, details: dict, indent: int = 0) -> List[str]:
943
+ """Format nested details as bullet points"""
944
+ bullets = []
945
+ prefix = " " * indent
946
+
947
+ for key, value in details.items():
948
+ if isinstance(value, dict):
949
+ bullets.append(f"{prefix}• {key}:")
950
+ bullets.extend(self._format_details_as_bullets(value, indent + 1))
951
+ elif isinstance(value, list):
952
+ bullets.append(f"{prefix}• {key}:")
953
+ for item in value:
954
+ bullets.append(f"{prefix} - {item}")
955
+ else:
956
+ formatted_value = f"{value:.3f}" if isinstance(value, float) else str(value)
957
+ bullets.append(f"{prefix}• {key}: {formatted_value}")
958
+
959
+ return bullets
960
+
961
+ def _extract_color_detail(self, details: dict, key: str) -> Any:
962
+ """Extract color detail from nested structure"""
963
+ if 'saturation_stats' in details and isinstance(details['saturation_stats'], dict):
964
+ if key in details['saturation_stats']:
965
+ return details['saturation_stats'][key]
966
+
967
+ if 'histogram_stats' in details and isinstance(details['histogram_stats'], dict):
968
+ if key in details['histogram_stats']:
969
+ return details['histogram_stats'][key]
970
+
971
+ if 'hue_stats' in details and isinstance(details['hue_stats'], dict):
972
+ if key in details['hue_stats']:
973
+ return details['hue_stats'][key]
974
+
975
+ return 'N/A'
976
+
977
+ def _extract_color_reason(self, details: dict) -> str:
978
+ """Extract reason from color details"""
979
+ if 'saturation_stats' in details and isinstance(details['saturation_stats'], dict):
980
+ if 'reason' in details['saturation_stats']:
981
+ return details['saturation_stats']['reason']
982
+
983
+ if 'hue_stats' in details and isinstance(details['hue_stats'], dict):
984
+ if 'reason' in details['hue_stats']:
985
+ return details['hue_stats']['reason']
986
+
987
+ return ''
988
+
989
+ def _get_status_html(self, status: str) -> str:
990
+ """Return colored status HTML"""
991
+ if status == "REVIEW_REQUIRED":
992
+ return f"<font color='{self.COLOR_DANGER}'><b>⚠ REVIEW REQUIRED</b></font>"
993
+ elif status == "LIKELY_AUTHENTIC":
994
+ return f"<font color='{self.COLOR_SUCCESS}'><b>✓ LIKELY AUTHENTIC</b></font>"
995
+ else:
996
+ return f"<font color='{self.COLOR_INFO}'><b>{status}</b></font>"
997
+
998
+ def _get_signal_status_html(self, status: str) -> str:
999
+ """Return signal status badge HTML"""
1000
+ if status == "flagged":
1001
+ return f"<font color='{self.COLOR_DANGER}'><b>🔴 FLAGGED</b></font>"
1002
+ elif status == "warning":
1003
+ return f"<font color='{self.COLOR_WARNING}'><b>🟠 WARNING</b></font>"
1004
+ else:
1005
+ return f"<font color='{self.COLOR_SUCCESS}'><b>🟢 PASSED</b></font>"
1006
+
1007
+ def _get_score_color(self, score: float) -> str:
1008
+ """Get color based on score"""
1009
+ if score >= 0.85:
1010
+ return self.COLOR_DANGER.toHex()
1011
+ elif score >= 0.70:
1012
+ return self.COLOR_WARNING.toHex()
1013
+ elif score >= 0.50:
1014
+ return colors.HexColor('#F1C40F').toHex()
1015
+ else:
1016
+ return self.COLOR_SUCCESS.toHex()
1017
+
1018
+ def _add_footer(self, story):
1019
+ """Add footer with cautions and watermark notice"""
1020
+ story.append(Spacer(1, 10))
1021
+
1022
+ # Caution box
1023
+ caution_text = "⚠️ <b>CAUTION</b>: Results are indicative and should be verified manually for critical applications. " \
1024
+ "For questions or support, contact: support@aiimagescreener.com"
1025
+
1026
+ caution_data = [[Paragraph(caution_text, self.styles['SmallText'])]]
1027
+ caution_table = Table(caution_data, colWidths=[560])
1028
+ caution_table.setStyle(TableStyle([
1029
+ ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#FFF3CD')),
1030
+ ('TEXTCOLOR', (0, 0), (-1, -1), colors.HexColor('#856404')),
1031
+ ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#FFEEBA')),
1032
+ ('LEFTPADDING', (0, 0), (-1, -1), 8),
1033
+ ('RIGHTPADDING', (0, 0), (-1, -1), 8),
1034
+ ('TOPPADDING', (0, 0), (-1, -1), 4),
1035
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 4)
1036
+ ]))
1037
+ story.append(caution_table)
1038
+
1039
+ # Watermark notice
1040
+ story.append(Spacer(1, 4))
1041
+ watermark_notice = Paragraph(
1042
+ "<i>Document contains security watermark. Unauthorized duplication prohibited.</i>",
1043
+ ParagraphStyle(
1044
+ name='WatermarkNotice',
1045
+ fontSize=6,
1046
+ textColor=colors.grey,
1047
+ alignment=TA_CENTER
1048
+ )
1049
+ )
1050
+ story.append(watermark_notice)
requirements.txt ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =========================================
2
+ # AI Image Screener - Requirements
3
+ # Python 3.11+
4
+ # =========================================
5
+
6
+ # Core Web Framework
7
+ fastapi==0.104.1
8
+ uvicorn[standard]==0.24.0
9
+ python-multipart==0.0.6
10
+
11
+ # Data Validation & Settings
12
+ pydantic==2.5.0
13
+ pydantic-settings==2.1.0
14
+ python-dotenv==1.0.0
15
+
16
+ # Image Processing
17
+ opencv-python-headless==4.8.1.78
18
+ Pillow==10.1.0
19
+ numpy==1.26.2
20
+ scipy==1.11.4
21
+
22
+ # File Type Detection
23
+ python-magic==0.4.27
24
+
25
+ # PDF Generation
26
+ reportlab==4.0.7
27
+
28
+ # ASGI Server Production
29
+ gunicorn==21.2.0
30
+
31
+ # Logging & Monitoring
32
+ colorama==0.4.6
33
+
34
+ # Security
35
+ python-jose[cryptography]==3.3.0
36
+ passlib[bcrypt]==1.7.4
37
+
38
+ # CORS & Middleware
39
+ starlette==0.27.0
40
+
41
+ # Testing (optional but recommended)
42
+ pytest==7.4.3
43
+ pytest-cov==4.1.0
44
+ pytest-asyncio==0.21.1
45
+ httpx==0.25.2
46
+
47
+ # Code Quality (optional)
48
+ black==23.12.0
49
+ flake8==6.1.0
50
+ isort==5.13.2
51
+ mypy==1.7.1
52
+
53
+ # Development Tools (optional)
54
+ ipython==8.18.1
55
+ ipdb==0.13.13
56
+
57
+ # =========================================
58
+ # Platform-Specific Notes:
59
+ # =========================================
60
+ #
61
+ # Linux (Ubuntu/Debian):
62
+ # sudo apt-get install -y libmagic1
63
+ #
64
+ # macOS:
65
+ # brew install libmagic
66
+ #
67
+ # Windows:
68
+ # pip install python-magic-bin==0.4.14
69
+ # (alternative to python-magic for Windows)
70
+ #
71
+ # =========================================
ui/index.html ADDED
@@ -0,0 +1,2248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI Image Screener</title>
7
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
8
+ <link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔍</text></svg>">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ :root {
17
+ --primary: #2d3748;
18
+ --primary-light: #4a5568;
19
+ --primary-dark: #1a202c;
20
+ --secondary: #718096;
21
+ --accent: #38a169;
22
+ --accent-light: #68d391;
23
+ --accent-dark: #2f855a;
24
+ --warning: #d69e2e;
25
+ --danger: #e53e3e;
26
+ --background: #f7fafc;
27
+ --card-bg: #ffffff;
28
+ --border: #e2e8f0;
29
+ --text: #2d3748;
30
+ --text-light: #718096;
31
+ --shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
32
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
33
+ }
34
+
35
+ body {
36
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
37
+ background-color: var(--background);
38
+ color: var(--text);
39
+ line-height: 1.6;
40
+ min-height: 100vh;
41
+ }
42
+
43
+ .container {
44
+ max-width: 1200px;
45
+ margin: 0 auto;
46
+ padding: 10px;
47
+ }
48
+
49
+ /* Header */
50
+ header {
51
+ background: linear-gradient(135deg, var(--primary-dark) 0%, #2d3748 100%);
52
+ color: white;
53
+ padding: 1.5rem 0;
54
+ margin-bottom: 1rem;
55
+ border-radius: 0 0 1rem 1rem;
56
+ box-shadow: var(--shadow-lg);
57
+ }
58
+
59
+ .header-content {
60
+ display: flex;
61
+ justify-content: space-between;
62
+ align-items: center;
63
+ flex-wrap: wrap;
64
+ gap: 1rem;
65
+ }
66
+
67
+ .logo {
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 0.75rem;
71
+ }
72
+
73
+ .logo-icon {
74
+ width: 40px;
75
+ height: 40px;
76
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
77
+ border-radius: 8px;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+ font-size: 1.25rem;
82
+ }
83
+
84
+ .logo-text h1 {
85
+ font-size: 1.5rem;
86
+ font-weight: 600;
87
+ }
88
+
89
+ .logo-text .tagline {
90
+ font-size: 0.875rem;
91
+ opacity: 0.8;
92
+ margin-top: 0.125rem;
93
+ }
94
+
95
+ /* Hero Section */
96
+ .hero {
97
+ background: linear-gradient(135deg, #2d3748 0%, #4a5568 100%);
98
+ border-radius: 1rem;
99
+ padding: 1.5rem 1.5rem;
100
+ text-align: center;
101
+ margin-bottom: 1rem;
102
+ color: white;
103
+ box-shadow: var(--shadow-lg);
104
+ }
105
+
106
+ .hero h2 {
107
+ font-size: 2.5rem;
108
+ margin-bottom: 1rem;
109
+ color: white;
110
+ }
111
+
112
+ .hero-subtitle {
113
+ font-size: 1.25rem;
114
+ color: rgba(255, 255, 255, 0.9);
115
+ margin-bottom: 2rem;
116
+ max-width: 800px;
117
+ margin-left: auto;
118
+ margin-right: auto;
119
+ }
120
+
121
+ .performance-badge {
122
+ display: inline-block;
123
+ padding: 0.75rem 1.5rem;
124
+ background-color: rgba(255, 255, 255, 0.1);
125
+ color: white;
126
+ border: 1px solid rgba(255, 255, 255, 0.2);
127
+ border-radius: 2rem;
128
+ font-size: 0.875rem;
129
+ margin-bottom: 1.5rem;
130
+ backdrop-filter: blur(10px);
131
+ }
132
+
133
+ .cta-button {
134
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
135
+ color: white;
136
+ border: none;
137
+ padding: 1rem 2.5rem;
138
+ font-size: 1.125rem;
139
+ border-radius: 0.5rem;
140
+ cursor: pointer;
141
+ font-weight: 600;
142
+ transition: all 0.3s;
143
+ display: inline-flex;
144
+ align-items: center;
145
+ justify-content: center;
146
+ gap: 0.75rem;
147
+ box-shadow: 0 4px 6px rgba(56, 161, 105, 0.2);
148
+ min-width: 200px;
149
+ margin: 0 auto;
150
+ }
151
+
152
+ .cta-button:hover {
153
+ transform: translateY(-2px);
154
+ box-shadow: 0 6px 12px rgba(56, 161, 105, 0.3);
155
+ }
156
+
157
+ /* Tab Navigation */
158
+ .tabs {
159
+ display: flex;
160
+ gap: 0.5rem;
161
+ margin-bottom: 2rem;
162
+ border-bottom: 2px solid var(--border);
163
+ padding-bottom: 0;
164
+ background-color: white;
165
+ border-radius: 0.5rem;
166
+ padding: 0.5rem;
167
+ box-shadow: var(--shadow);
168
+ }
169
+
170
+ .tab-button {
171
+ padding: 1rem 2rem;
172
+ background: none;
173
+ border: none;
174
+ border-bottom: 3px solid transparent;
175
+ color: var(--text-light);
176
+ font-weight: 600;
177
+ cursor: pointer;
178
+ transition: all 0.3s;
179
+ position: relative;
180
+ flex: 1;
181
+ text-align: center;
182
+ border-radius: 0.25rem;
183
+ }
184
+
185
+ .tab-button.active {
186
+ color: var(--accent);
187
+ border-bottom-color: var(--accent);
188
+ background-color: rgba(56, 161, 105, 0.05);
189
+ }
190
+
191
+ .tab-button:hover:not(.active) {
192
+ color: var(--primary);
193
+ background-color: rgba(0, 0, 0, 0.02);
194
+ }
195
+
196
+ .tab-content {
197
+ display: none;
198
+ animation: fadeIn 0.5s ease;
199
+ }
200
+
201
+ .tab-content.active {
202
+ display: block;
203
+ }
204
+
205
+ @keyframes fadeIn {
206
+ from { opacity: 0; transform: translateY(10px); }
207
+ to { opacity: 1; transform: translateY(0); }
208
+ }
209
+
210
+ /* Features Grid */
211
+ .features-grid {
212
+ display: grid;
213
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
214
+ gap: 1.5rem;
215
+ margin-bottom: 2rem;
216
+ }
217
+
218
+ .feature-card {
219
+ background-color: white;
220
+ border-radius: 1rem;
221
+ padding: 1.5rem;
222
+ border: 1px solid var(--border);
223
+ transition: all 0.3s;
224
+ }
225
+
226
+ .feature-card:hover {
227
+ transform: translateY(-5px);
228
+ box-shadow: var(--shadow-lg);
229
+ border-color: var(--accent-light);
230
+ }
231
+
232
+ .feature-icon {
233
+ font-size: 2rem;
234
+ color: var(--accent);
235
+ margin-bottom: 1rem;
236
+ }
237
+
238
+ /* Metrics Grid - Updated for Detailed Cards */
239
+ .metrics-grid {
240
+ display: grid;
241
+ grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
242
+ gap: 1.5rem;
243
+ margin-bottom: 2rem;
244
+ }
245
+
246
+ @media (max-width: 768px) {
247
+ .metrics-grid {
248
+ grid-template-columns: 1fr;
249
+ }
250
+ }
251
+
252
+ .metric-card {
253
+ background-color: white;
254
+ border-radius: 1rem;
255
+ padding: 1.5rem;
256
+ border: 1px solid var(--border);
257
+ transition: all 0.3s;
258
+ display: flex;
259
+ flex-direction: column;
260
+ height: 100%;
261
+ }
262
+
263
+ .metric-card:hover {
264
+ transform: translateY(-5px);
265
+ box-shadow: var(--shadow-lg);
266
+ }
267
+
268
+ .metric-header {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 1rem;
272
+ margin-bottom: 1rem;
273
+ padding-bottom: 1rem;
274
+ border-bottom: 1px solid var(--border);
275
+ }
276
+
277
+ .metric-icon {
278
+ width: 3rem;
279
+ height: 3rem;
280
+ border-radius: 0.75rem;
281
+ display: flex;
282
+ align-items: center;
283
+ justify-content: center;
284
+ color: white;
285
+ font-size: 1.5rem;
286
+ flex-shrink: 0;
287
+ }
288
+
289
+ .metric-title {
290
+ font-size: 1.25rem;
291
+ font-weight: 600;
292
+ color: var(--primary);
293
+ }
294
+
295
+ .metric-weight {
296
+ display: inline-block;
297
+ padding: 0.25rem 0.75rem;
298
+ background-color: rgba(56, 161, 105, 0.1);
299
+ color: var(--accent);
300
+ border-radius: 2rem;
301
+ font-size: 0.875rem;
302
+ font-weight: 600;
303
+ margin-left: auto;
304
+ }
305
+
306
+ .metric-description {
307
+ color: var(--text-light);
308
+ margin-bottom: 1rem;
309
+ line-height: 1.6;
310
+ }
311
+
312
+ .metric-details {
313
+ margin-top: auto;
314
+ padding-top: 1rem;
315
+ border-top: 1px solid var(--border);
316
+ }
317
+
318
+ .detail-item {
319
+ display: flex;
320
+ justify-content: space-between;
321
+ margin-bottom: 0.5rem;
322
+ font-size: 0.875rem;
323
+ }
324
+
325
+ .detail-label {
326
+ color: var(--text-light);
327
+ }
328
+
329
+ .detail-value {
330
+ color: var(--primary);
331
+ font-weight: 500;
332
+ }
333
+
334
+ /* How-to-use Steps */
335
+ .steps-grid {
336
+ display: grid;
337
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
338
+ gap: 1.5rem;
339
+ margin-bottom: 2rem;
340
+ }
341
+
342
+ .step-card {
343
+ text-align: center;
344
+ padding: 2rem;
345
+ background-color: white;
346
+ border-radius: 1rem;
347
+ border: 1px solid var(--border);
348
+ transition: all 0.3s;
349
+ }
350
+
351
+ .step-card:hover {
352
+ transform: translateY(-5px);
353
+ border-color: var(--accent);
354
+ }
355
+
356
+ .step-number {
357
+ display: inline-flex;
358
+ align-items: center;
359
+ justify-content: center;
360
+ width: 3rem;
361
+ height: 3rem;
362
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
363
+ color: white;
364
+ border-radius: 50%;
365
+ font-size: 1.5rem;
366
+ font-weight: bold;
367
+ margin-bottom: 1rem;
368
+ }
369
+
370
+ /* Cards */
371
+ .card {
372
+ background-color: var(--card-bg);
373
+ border-radius: 1rem;
374
+ font-size: 1.00rem;
375
+ box-shadow: var(--shadow);
376
+ padding: 1.0rem;
377
+ margin-bottom: 0.5rem;
378
+ border: 1px solid var(--border);
379
+ }
380
+
381
+ .card-header {
382
+ display: flex;
383
+ justify-content: space-between;
384
+ align-items: center;
385
+ margin-bottom: 0.5rem;
386
+ padding-bottom: 0.75rem;
387
+ border-bottom: 0.5px solid var(--border);
388
+ }
389
+
390
+ .card-title {
391
+ font-size: 1.25rem;
392
+ font-weight: 600;
393
+ display: flex;
394
+ align-items: center;
395
+ gap: 0.5rem;
396
+ }
397
+
398
+ /* Upload Section */
399
+ .upload-area {
400
+ border: 2px dashed var(--border);
401
+ border-radius: 1rem;
402
+ padding: 3rem 1.5rem;
403
+ text-align: center;
404
+ transition: all 0.3s ease;
405
+ cursor: pointer;
406
+ margin-bottom: 1rem;
407
+ background-color: #f8fafc;
408
+ }
409
+
410
+ .upload-area:hover, .upload-area.dragover {
411
+ border-color: var(--accent);
412
+ background-color: rgba(56, 161, 105, 0.05);
413
+ }
414
+
415
+ .upload-icon {
416
+ font-size: 3rem;
417
+ color: var(--accent);
418
+ margin-bottom: 1rem;
419
+ }
420
+
421
+ .upload-button {
422
+ background-color: var(--accent);
423
+ color: white;
424
+ border: none;
425
+ padding: 0.75rem 1.5rem;
426
+ border-radius: 0.5rem;
427
+ font-weight: 600;
428
+ cursor: pointer;
429
+ transition: all 0.3s;
430
+ display: inline-flex;
431
+ align-items: center;
432
+ justify-content: center;
433
+ gap: 0.5rem;
434
+ }
435
+
436
+ .upload-button:hover {
437
+ background-color: var(--accent-dark);
438
+ transform: translateY(-2px);
439
+ }
440
+
441
+ /* Thumbnail Grid */
442
+ .thumbnail-grid {
443
+ display: grid;
444
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
445
+ gap: 1rem;
446
+ margin-top: 1rem;
447
+ max-height: 300px;
448
+ overflow-y: auto;
449
+ padding: 0.5rem;
450
+ }
451
+
452
+ .thumbnail-item {
453
+ position: relative;
454
+ border-radius: 0.5rem;
455
+ overflow: hidden;
456
+ border: 2px solid var(--border);
457
+ transition: all 0.3s;
458
+ height: 120px;
459
+ }
460
+
461
+ .thumbnail-item:hover {
462
+ border-color: var(--accent);
463
+ transform: translateY(-2px);
464
+ }
465
+
466
+ .thumbnail-img {
467
+ width: 100%;
468
+ height: 100%;
469
+ object-fit: cover;
470
+ }
471
+
472
+ .thumbnail-overlay {
473
+ position: absolute;
474
+ bottom: 0;
475
+ left: 0;
476
+ right: 0;
477
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
478
+ padding: 0.5rem;
479
+ color: white;
480
+ font-size: 0.75rem;
481
+ display: flex;
482
+ justify-content: space-between;
483
+ align-items: center;
484
+ }
485
+
486
+ .remove-thumbnail {
487
+ background: rgba(229, 62, 62, 0.8);
488
+ border: none;
489
+ color: white;
490
+ width: 24px;
491
+ height: 24px;
492
+ border-radius: 50%;
493
+ display: flex;
494
+ align-items: center;
495
+ justify-content: center;
496
+ cursor: pointer;
497
+ transition: all 0.3s;
498
+ }
499
+
500
+ .remove-thumbnail:hover {
501
+ background: var(--danger);
502
+ transform: scale(1.1);
503
+ }
504
+
505
+ /* Start Analysis Button - Centered */
506
+ .start-analysis-btn {
507
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
508
+ color: white;
509
+ border: none;
510
+ padding: 1rem 2rem;
511
+ font-size: 1.125rem;
512
+ border-radius: 0.5rem;
513
+ cursor: pointer;
514
+ font-weight: 600;
515
+ transition: all 0.3s;
516
+ display: flex;
517
+ align-items: center;
518
+ justify-content: center;
519
+ gap: 0.75rem;
520
+ width: 100%;
521
+ margin-top: 1.5rem;
522
+ box-shadow: 0 4px 6px rgba(56, 161, 105, 0.2);
523
+ }
524
+
525
+ .start-analysis-btn:hover:not(:disabled) {
526
+ transform: translateY(-2px);
527
+ box-shadow: 0 6px 12px rgba(56, 161, 105, 0.3);
528
+ }
529
+
530
+ .start-analysis-btn:disabled {
531
+ opacity: 0.5;
532
+ cursor: not-allowed;
533
+ transform: none !important;
534
+ }
535
+
536
+ .start-analysis-btn .btn-content {
537
+ display: flex;
538
+ align-items: center;
539
+ justify-content: center;
540
+ gap: 0.75rem;
541
+ }
542
+
543
+ /* Progress Section */
544
+ .progress-container {
545
+ margin-top: 1rem;
546
+ padding: 1rem;
547
+ background-color: white;
548
+ border-radius: 0.5rem;
549
+ box-shadow: var(--shadow);
550
+ border: 1px solid var(--border);
551
+ }
552
+
553
+ .progress-header {
554
+ display: flex;
555
+ justify-content: space-between;
556
+ margin-bottom: 0.5rem;
557
+ }
558
+
559
+ .progress-bar {
560
+ height: 0.5rem;
561
+ background-color: var(--border);
562
+ border-radius: 1rem;
563
+ overflow: hidden;
564
+ margin-bottom: 0.5rem;
565
+ }
566
+
567
+ .progress-fill {
568
+ height: 100%;
569
+ background: linear-gradient(90deg, var(--accent), var(--accent-light));
570
+ border-radius: 1rem;
571
+ width: 0%;
572
+ transition: width 0.5s ease;
573
+ }
574
+
575
+ /* Results Section */
576
+ .results-summary {
577
+ display: grid;
578
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
579
+ gap: 1rem;
580
+ margin-bottom: 1.5rem;
581
+ }
582
+
583
+ .summary-card {
584
+ text-align: center;
585
+ padding: 1.5rem;
586
+ border-radius: 1rem;
587
+ background-color: white;
588
+ border: 1px solid var(--border);
589
+ transition: transform 0.3s;
590
+ }
591
+
592
+ .summary-card:hover {
593
+ transform: translateY(-3px);
594
+ }
595
+
596
+ .summary-value {
597
+ font-size: 2rem;
598
+ font-weight: 700;
599
+ margin-bottom: 0.25rem;
600
+ }
601
+
602
+ .summary-label {
603
+ font-size: 0.875rem;
604
+ color: var(--text-light);
605
+ }
606
+
607
+ .results-table-container {
608
+ overflow-x: auto;
609
+ margin-top: 1.5rem;
610
+ border-radius: 0.5rem;
611
+ border: 1px solid var(--border);
612
+ background-color: white;
613
+ }
614
+
615
+ .results-table {
616
+ width: 100%;
617
+ border-collapse: collapse;
618
+ }
619
+
620
+ .results-table th {
621
+ background-color: #f8fafc;
622
+ color: var(--text);
623
+ padding: 1rem;
624
+ text-align: left;
625
+ font-weight: 600;
626
+ border-bottom: 1px solid var(--border);
627
+ }
628
+
629
+ .results-table td {
630
+ padding: 1rem;
631
+ border-bottom: 1px solid var(--border);
632
+ vertical-align: middle;
633
+ }
634
+
635
+ .results-table tr:hover {
636
+ background-color: #f8fafc;
637
+ }
638
+
639
+ .status-badge {
640
+ display: inline-block;
641
+ padding: 0.25rem 0.75rem;
642
+ border-radius: 2rem;
643
+ font-size: 0.75rem;
644
+ font-weight: 600;
645
+ white-space: nowrap;
646
+ }
647
+
648
+ .status-authentic {
649
+ background-color: rgba(56, 161, 105, 0.1);
650
+ color: var(--accent);
651
+ border: 1px solid rgba(56, 161, 105, 0.3);
652
+ }
653
+
654
+ .status-review {
655
+ background-color: rgba(214, 158, 46, 0.1);
656
+ color: var(--warning);
657
+ border: 1px solid rgba(214, 158, 46, 0.3);
658
+ }
659
+
660
+ .score-indicator {
661
+ display: flex;
662
+ align-items: center;
663
+ gap: 0.5rem;
664
+ min-width: 150px;
665
+ }
666
+
667
+ .score-bar {
668
+ flex: 1;
669
+ height: 0.5rem;
670
+ background-color: var(--border);
671
+ border-radius: 1rem;
672
+ overflow: hidden;
673
+ }
674
+
675
+ .score-fill {
676
+ height: 100%;
677
+ border-radius: 1rem;
678
+ transition: width 0.5s ease;
679
+ }
680
+
681
+ .score-low {
682
+ background: linear-gradient(90deg, var(--accent), var(--accent-light));
683
+ }
684
+
685
+ .score-medium {
686
+ background: linear-gradient(90deg, var(--warning), #ecc94b);
687
+ }
688
+
689
+ .score-high {
690
+ background: linear-gradient(90deg, var(--danger), #fc8181);
691
+ }
692
+
693
+ /* Detailed Analysis */
694
+ .detailed-analysis {
695
+ margin-top: 2rem;
696
+ padding: 1.5rem;
697
+ background-color: white;
698
+ border-radius: 1rem;
699
+ border: 1px solid var(--border);
700
+ }
701
+
702
+ .analysis-header {
703
+ display: flex;
704
+ justify-content: space-between;
705
+ align-items: center;
706
+ margin-bottom: 1.5rem;
707
+ cursor: pointer;
708
+ padding: 0.5rem;
709
+ border-radius: 0.5rem;
710
+ transition: background-color 0.3s;
711
+ }
712
+
713
+ .analysis-header:hover {
714
+ background-color: #f8fafc;
715
+ }
716
+
717
+ .analysis-content {
718
+ display: none;
719
+ padding-top: 1rem;
720
+ border-top: 1px solid var(--border);
721
+ animation: fadeIn 0.5s ease;
722
+ }
723
+
724
+ .analysis-content.show {
725
+ display: block;
726
+ }
727
+
728
+ .signal-grid {
729
+ display: grid;
730
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
731
+ gap: 1rem;
732
+ margin-bottom: 1.5rem;
733
+ }
734
+
735
+ .signal-card {
736
+ padding: 1rem;
737
+ border-radius: 0.5rem;
738
+ border: 1px solid var(--border);
739
+ background-color: #f8fafc;
740
+ }
741
+
742
+ .signal-header {
743
+ display: flex;
744
+ justify-content: space-between;
745
+ align-items: center;
746
+ margin-bottom: 0.5rem;
747
+ }
748
+
749
+ .signal-badge {
750
+ padding: 0.25rem 0.5rem;
751
+ border-radius: 0.375rem;
752
+ font-size: 0.75rem;
753
+ font-weight: 500;
754
+ border: 1px solid;
755
+ white-space: nowrap;
756
+ }
757
+
758
+ .signal-passed {
759
+ background-color: rgba(56, 161, 105, 0.1);
760
+ color: var(--accent);
761
+ border-color: rgba(56, 161, 105, 0.3);
762
+ }
763
+
764
+ .signal-warning {
765
+ background-color: rgba(214, 158, 46, 0.1);
766
+ color: var(--warning);
767
+ border-color: rgba(214, 158, 46, 0.3);
768
+ }
769
+
770
+ .signal-flagged {
771
+ background-color: rgba(229, 62, 62, 0.1);
772
+ color: var(--danger);
773
+ border-color: rgba(229, 62, 62, 0.3);
774
+ }
775
+
776
+ /* Footer - Reduced spacing */
777
+ footer {
778
+ margin-top: 0.1rem;
779
+ padding-top: 0.1rem;
780
+ border-top: 1px solid var(--border);
781
+ color: var(--text-light);
782
+ font-size: 0.875rem;
783
+ text-align: center;
784
+ }
785
+
786
+ .footer-links {
787
+ display: flex;
788
+ justify-content: center;
789
+ gap: 2rem;
790
+ margin-bottom: 1rem;
791
+ flex-wrap: wrap;
792
+ }
793
+
794
+ .footer-link {
795
+ color: var(--accent);
796
+ text-decoration: none;
797
+ transition: color 0.3s;
798
+ font-size: 0.875rem;
799
+ }
800
+
801
+ .footer-link:hover {
802
+ color: var(--accent-dark);
803
+ text-decoration: underline;
804
+ }
805
+
806
+ /* Action buttons */
807
+ .action-button {
808
+ padding: 0.5rem 1rem;
809
+ border: none;
810
+ border-radius: 0.5rem;
811
+ font-weight: 500;
812
+ cursor: pointer;
813
+ transition: all 0.3s;
814
+ display: inline-flex;
815
+ align-items: center;
816
+ justify-content: center;
817
+ gap: 0.5rem;
818
+ font-size: 0.875rem;
819
+ }
820
+
821
+ .primary-action {
822
+ background-color: var(--accent);
823
+ color: white;
824
+ }
825
+
826
+ .primary-action:hover {
827
+ background-color: var(--accent-dark);
828
+ }
829
+
830
+ .secondary-action {
831
+ background-color: white;
832
+ color: var(--accent);
833
+ border: 1px solid var(--accent);
834
+ }
835
+
836
+ .secondary-action:hover {
837
+ background-color: rgba(56, 161, 105, 0.1);
838
+ }
839
+
840
+ /* Loading overlay */
841
+ .loading-overlay {
842
+ position: fixed;
843
+ top: 0;
844
+ left: 0;
845
+ right: 0;
846
+ bottom: 0;
847
+ background-color: rgba(0, 0, 0, 0.5);
848
+ display: flex;
849
+ align-items: center;
850
+ justify-content: center;
851
+ z-index: 1000;
852
+ opacity: 0;
853
+ visibility: hidden;
854
+ transition: all 0.3s;
855
+ }
856
+
857
+ .loading-overlay.active {
858
+ opacity: 1;
859
+ visibility: visible;
860
+ }
861
+
862
+ .loading-spinner {
863
+ width: 60px;
864
+ height: 60px;
865
+ border: 4px solid rgba(255, 255, 255, 0.3);
866
+ border-radius: 50%;
867
+ border-top-color: white;
868
+ animation: spin 1s ease-in-out infinite;
869
+ }
870
+
871
+ @keyframes spin {
872
+ to { transform: rotate(360deg); }
873
+ }
874
+
875
+ /* Toast notification */
876
+ .toast {
877
+ position: fixed;
878
+ top: 20px;
879
+ right: 20px;
880
+ padding: 1rem 1.5rem;
881
+ background-color: white;
882
+ color: var(--text);
883
+ border-radius: 0.5rem;
884
+ box-shadow: var(--shadow-lg);
885
+ z-index: 1000;
886
+ transform: translateX(100%);
887
+ transition: transform 0.3s ease;
888
+ max-width: 300px;
889
+ border-left: 4px solid var(--accent);
890
+ }
891
+
892
+ .toast.show {
893
+ transform: translateX(0);
894
+ }
895
+
896
+ .toast.error {
897
+ border-left-color: var(--danger);
898
+ }
899
+
900
+ .toast.warning {
901
+ border-left-color: var(--warning);
902
+ }
903
+
904
+ /* Utility classes */
905
+ .hidden {
906
+ display: none !important;
907
+ }
908
+
909
+ .visible {
910
+ display: block !important;
911
+ }
912
+
913
+ .text-center {
914
+ text-align: center;
915
+ }
916
+
917
+ .mt-1 { margin-top: 0.5rem; }
918
+ .mt-2 { margin-top: 1rem; }
919
+ .mt-3 { margin-top: 1.5rem; }
920
+ .mb-1 { margin-bottom: 0.5rem; }
921
+ .mb-2 { margin-bottom: 1rem; }
922
+ .mb-3 { margin-bottom: 1.5rem; }
923
+
924
+ /* Responsive adjustments */
925
+ @media (max-width: 768px) {
926
+ .hero h2 {
927
+ font-size: 2rem;
928
+ }
929
+
930
+ .hero-subtitle {
931
+ font-size: 1rem;
932
+ }
933
+
934
+ .tabs {
935
+ flex-direction: column;
936
+ }
937
+
938
+ .tab-button {
939
+ width: 100%;
940
+ text-align: center;
941
+ }
942
+
943
+ .metrics-grid {
944
+ grid-template-columns: 1fr;
945
+ }
946
+
947
+ .signal-grid {
948
+ grid-template-columns: 1fr;
949
+ }
950
+
951
+ .footer-links {
952
+ flex-direction: column;
953
+ gap: 0.75rem;
954
+ }
955
+ }
956
+
957
+ /* Spinner for loading button */
958
+ .spinner {
959
+ display: inline-block;
960
+ width: 1rem;
961
+ height: 1rem;
962
+ border: 2px solid rgba(255, 255, 255, 0.3);
963
+ border-radius: 50%;
964
+ border-top-color: white;
965
+ animation: spin 1s ease-in-out infinite;
966
+ margin-right: 0.5rem;
967
+ }
968
+ </style>
969
+ </head>
970
+ <body>
971
+ <!-- Loading Overlay -->
972
+ <div class="loading-overlay" id="loadingOverlay">
973
+ <div class="loading-spinner"></div>
974
+ </div>
975
+
976
+ <!-- Toast Notification -->
977
+ <div class="toast hidden" id="toast"></div>
978
+
979
+ <!-- Header -->
980
+ <header>
981
+ <div class="container">
982
+ <div class="header-content">
983
+ <div class="logo">
984
+ <div class="logo-icon">
985
+ <i class="fas fa-filter"></i>
986
+ </div>
987
+ <div class="logo-text">
988
+ <h1>AI Image Screener</h1>
989
+ <div class="tagline">First-pass screening for bulk workflows</div>
990
+ </div>
991
+ </div>
992
+ </div>
993
+ </div>
994
+ </header>
995
+
996
+ <!-- Main Content -->
997
+ <div class="container">
998
+ <!-- Landing Screen -->
999
+ <div id="landingScreen">
1000
+ <!-- Hero Section -->
1001
+ <section class="hero">
1002
+ <h2>AI Image Screener</h2>
1003
+ <p class="hero-subtitle">
1004
+ A practical first-pass AI image screening system designed to identify images that require human review based on statistical and physical patterns.
1005
+ </p>
1006
+ <div class="performance-badge">
1007
+ <i class="fas fa-chart-line"></i> Screening accuracy: 40-90% detection rate across AI models
1008
+ </div>
1009
+ <br>
1010
+ <button class="cta-button" id="tryNowBtn">
1011
+ <div class="btn-content">
1012
+ <i class="fas fa-play-circle"></i> Start Screening
1013
+ </div>
1014
+ </button>
1015
+ </section>
1016
+
1017
+ <!-- Tab Navigation -->
1018
+ <div class="tabs">
1019
+ <button class="tab-button active" data-tab="features">Features</button>
1020
+ <button class="tab-button" data-tab="metrics">Detection Metrics</button>
1021
+ <button class="tab-button" data-tab="howto">How to Use</button>
1022
+ </div>
1023
+
1024
+ <!-- Features Tab -->
1025
+ <div class="tab-content active" id="featuresTab">
1026
+ <div class="features-grid">
1027
+ <div class="feature-card">
1028
+ <div class="feature-icon">
1029
+ <i class="fas fa-bolt"></i>
1030
+ </div>
1031
+ <h3>Fast Processing</h3>
1032
+ <p>Parallel processing for batch analysis with real-time progress tracking</p>
1033
+ </div>
1034
+
1035
+ <div class="feature-card">
1036
+ <div class="feature-icon">
1037
+ <i class="fas fa-chart-bar"></i>
1038
+ </div>
1039
+ <h3>Multi-Signal Detection</h3>
1040
+ <p>Five independent statistical detectors with weighted ensemble aggregation</p>
1041
+ </div>
1042
+
1043
+ <div class="feature-card">
1044
+ <div class="feature-icon">
1045
+ <i class="fas fa-file-export"></i>
1046
+ </div>
1047
+ <h3>Comprehensive Reports</h3>
1048
+ <p>Export results in CSV, JSON, and PDF formats for integration and documentation</p>
1049
+ </div>
1050
+
1051
+ <div class="feature-card">
1052
+ <div class="feature-icon">
1053
+ <i class="fas fa-sliders-h"></i>
1054
+ </div>
1055
+ <h3>Adjustable Sensitivity</h3>
1056
+ <p>Conservative, balanced, and aggressive modes for different use cases</p>
1057
+ </div>
1058
+ </div>
1059
+
1060
+ <!-- Caution Notice -->
1061
+ <div class="card">
1062
+ <div class="card-header">
1063
+ <h3 class="card-title"><i class="fas fa-exclamation-triangle" style="color: var(--warning);"></i> Important Notice</h3>
1064
+ </div>
1065
+ <p style="color: var(--text-light);">
1066
+ <strong>This is not a perfect AI detector. It's a screening tool that helps reduce manual review workload by flagging suspicious images for human verification.</strong>
1067
+ </p>
1068
+ </div>
1069
+ </div>
1070
+
1071
+ <!-- Metrics Tab - Updated with Detailed Cards -->
1072
+ <div class="tab-content" id="metricsTab">
1073
+ <div class="metrics-grid">
1074
+ <div class="metric-card">
1075
+ <div class="metric-header">
1076
+ <div class="metric-icon" style="background: linear-gradient(135deg, #4a5568 0%, #718096 100%);">
1077
+ <i class="fas fa-wave-square"></i>
1078
+ </div>
1079
+ <div>
1080
+ <div class="metric-title">Gradient-Field PCA</div>
1081
+ </div>
1082
+ <span class="metric-weight">Weight: 30%</span>
1083
+ </div>
1084
+ <p class="metric-description">
1085
+ Detects lighting & gradient inconsistencies typical of diffusion models. Analyzes directional light patterns and shadow consistency that often appear unnatural in AI-generated images.
1086
+ </p>
1087
+ <div class="metric-details">
1088
+ <div class="detail-item">
1089
+ <span class="detail-label">Detection Method</span>
1090
+ <span class="detail-value">Principal Component Analysis</span>
1091
+ </div>
1092
+ <div class="detail-item">
1093
+ <span class="detail-label">Sensitivity</span>
1094
+ <span class="detail-value">High for diffusion models</span>
1095
+ </div>
1096
+ <div class="detail-item">
1097
+ <span class="detail-label">Performance</span>
1098
+ <span class="detail-value">85-95% detection rate</span>
1099
+ </div>
1100
+ </div>
1101
+ </div>
1102
+
1103
+ <div class="metric-card">
1104
+ <div class="metric-header">
1105
+ <div class="metric-icon" style="background: linear-gradient(135deg, #718096 0%, #a0aec0 100%);">
1106
+ <i class="fas fa-chart-line"></i>
1107
+ </div>
1108
+ <div>
1109
+ <div class="metric-title">Frequency Analysis</div>
1110
+ </div>
1111
+ <span class="metric-weight">Weight: 25%</span>
1112
+ </div>
1113
+ <p class="metric-description">
1114
+ Identifies unnatural spectral energy distributions via FFT analysis. AI-generated images often show characteristic frequency patterns different from camera-captured photos.
1115
+ </p>
1116
+ <div class="metric-details">
1117
+ <div class="detail-item">
1118
+ <span class="detail-label">Detection Method</span>
1119
+ <span class="detail-value">Fast Fourier Transform</span>
1120
+ </div>
1121
+ <div class="detail-item">
1122
+ <span class="detail-label">Sensitivity</span>
1123
+ <span class="detail-value">Medium-High</span>
1124
+ </div>
1125
+ <div class="detail-item">
1126
+ <span class="detail-label">Performance</span>
1127
+ <span class="detail-value">75-85% detection rate</span>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <div class="metric-card">
1133
+ <div class="metric-header">
1134
+ <div class="metric-icon" style="background: linear-gradient(135deg, #38a169 0%, #68d391 100%);">
1135
+ <i class="fas fa-braille"></i>
1136
+ </div>
1137
+ <div>
1138
+ <div class="metric-title">Noise Pattern Analysis</div>
1139
+ </div>
1140
+ <span class="metric-weight">Weight: 20%</span>
1141
+ </div>
1142
+ <p class="metric-description">
1143
+ Detects missing or artificial sensor noise patterns. Real cameras produce characteristic noise while AI models often generate unnaturally uniform or missing noise patterns.
1144
+ </p>
1145
+ <div class="metric-details">
1146
+ <div class="detail-item">
1147
+ <span class="detail-label">Detection Method</span>
1148
+ <span class="detail-value">Noise Distribution Analysis</span>
1149
+ </div>
1150
+ <div class="detail-item">
1151
+ <span class="detail-label">Sensitivity</span>
1152
+ <span class="detail-value">Medium</span>
1153
+ </div>
1154
+ <div class="detail-item">
1155
+ <span class="detail-label">Performance</span>
1156
+ <span class="detail-value">70-80% detection rate</span>
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+
1161
+ <div class="metric-card">
1162
+ <div class="metric-header">
1163
+ <div class="metric-icon" style="background: linear-gradient(135deg, #d69e2e 0%, #ecc94b 100%);">
1164
+ <i class="fas fa-text-height"></i>
1165
+ </div>
1166
+ <div>
1167
+ <div class="metric-title">Texture Statistics</div>
1168
+ </div>
1169
+ <span class="metric-weight">Weight: 15%</span>
1170
+ </div>
1171
+ <p class="metric-description">
1172
+ Identifies overly smooth or uniform texture regions. AI-generated images often lack the natural texture variation found in real photographs, especially in complex surfaces.
1173
+ </p>
1174
+ <div class="metric-details">
1175
+ <div class="detail-item">
1176
+ <span class="detail-label">Detection Method</span>
1177
+ <span class="detail-value">GLCM Texture Analysis</span>
1178
+ </div>
1179
+ <div class="detail-item">
1180
+ <span class="detail-label">Sensitivity</span>
1181
+ <span class="detail-value">Medium-Low</span>
1182
+ </div>
1183
+ <div class="detail-item">
1184
+ <span class="detail-label">Performance</span>
1185
+ <span class="detail-value">60-70% detection rate</span>
1186
+ </div>
1187
+ </div>
1188
+ </div>
1189
+
1190
+ <div class="metric-card">
1191
+ <div class="metric-header">
1192
+ <div class="metric-icon" style="background: linear-gradient(135deg, #e53e3e 0%, #fc8181 100%);">
1193
+ <i class="fas fa-palette"></i>
1194
+ </div>
1195
+ <div>
1196
+ <div class="metric-title">Color Distribution</div>
1197
+ </div>
1198
+ <span class="metric-weight">Weight: 10%</span>
1199
+ </div>
1200
+ <p class="metric-description">
1201
+ Flags unnatural saturation and color histogram patterns. AI models often produce colors that are either oversaturated or have distribution patterns that differ from real photographs.
1202
+ </p>
1203
+ <div class="metric-details">
1204
+ <div class="detail-item">
1205
+ <span class="detail-label">Detection Method</span>
1206
+ <span class="detail-value">Color Histogram Analysis</span>
1207
+ </div>
1208
+ <div class="detail-item">
1209
+ <span class="detail-label">Sensitivity</span>
1210
+ <span class="detail-value">Low-Medium</span>
1211
+ </div>
1212
+ <div class="detail-item">
1213
+ <span class="detail-label">Performance</span>
1214
+ <span class="detail-value">50-65% detection rate</span>
1215
+ </div>
1216
+ </div>
1217
+ </div>
1218
+ </div>
1219
+ </div>
1220
+
1221
+ <!-- How-to-use Tab -->
1222
+ <div class="tab-content" id="howtoTab">
1223
+ <div class="steps-grid">
1224
+ <div class="step-card">
1225
+ <div class="step-number">1</div>
1226
+ <h3>Upload Images</h3>
1227
+ <p>Drag & drop or select images (JPG, PNG, WEBP)</p>
1228
+ </div>
1229
+
1230
+ <div class="step-card">
1231
+ <div class="step-number">2</div>
1232
+ <h3>Start Analysis</h3>
1233
+ <p>Click "Start Analysis" to begin screening</p>
1234
+ </div>
1235
+
1236
+ <div class="step-card">
1237
+ <div class="step-number">3</div>
1238
+ <h3>Review Results</h3>
1239
+ <p>Check flagged images and export reports</p>
1240
+ </div>
1241
+ </div>
1242
+ </div>
1243
+ </div>
1244
+
1245
+ <!-- Analysis Screen (Initially Hidden) -->
1246
+ <div id="analysisScreen" class="hidden">
1247
+ <!-- Upload Card -->
1248
+ <div class="card">
1249
+ <div class="card-header">
1250
+ <h2 class="card-title"><i class="fas fa-cloud-upload-alt"></i> Upload Images</h2>
1251
+ <button class="action-button secondary-action" id="backHomeBtn">
1252
+ <i class="fas fa-arrow-left"></i> Back
1253
+ </button>
1254
+ </div>
1255
+
1256
+ <div class="upload-area" id="uploadArea">
1257
+ <div class="upload-icon">
1258
+ <i class="fas fa-cloud-upload-alt"></i>
1259
+ </div>
1260
+ <h3 class="upload-text">Drag & drop images here</h3>
1261
+ <p class="upload-text">or</p>
1262
+ <div class="upload-button" id="fileInputBtn">
1263
+ <i class="fas fa-folder-open"></i> Browse Files
1264
+ </div>
1265
+ <input type="file" id="fileInput" multiple accept=".jpg,.jpeg,.png,.webp" style="display: none;">
1266
+ <p class="text-center mt-2" style="color: var(--text-light); font-size: 0.875rem;">
1267
+ Supports JPG, JPEG, PNG, WEBP up to 10MB each
1268
+ </p>
1269
+ </div>
1270
+
1271
+ <!-- Thumbnail Grid -->
1272
+ <div class="thumbnail-grid" id="thumbnailGrid"></div>
1273
+
1274
+ <!-- Start Analysis Button - Centered -->
1275
+ <div class="mt-3" id="analyzeButtonContainer" style="display: none;">
1276
+ <button class="start-analysis-btn" id="analyzeBtn">
1277
+ <div class="btn-content">
1278
+ <i class="fas fa-play"></i> Start Analysis
1279
+ </div>
1280
+ </button>
1281
+ </div>
1282
+
1283
+ <div class="progress-container hidden" id="progressContainer">
1284
+ <div class="progress-header">
1285
+ <span>Processing</span>
1286
+ <span id="progressPercent">0%</span>
1287
+ </div>
1288
+ <div class="progress-bar">
1289
+ <div class="progress-fill" id="progressFill"></div>
1290
+ </div>
1291
+ <div class="progress-details">
1292
+ <span id="currentFile" class="current-file">Ready to process</span>
1293
+ <span id="progressStats">0 / 0</span>
1294
+ </div>
1295
+ </div>
1296
+ </div>
1297
+
1298
+ <!-- Results Section -->
1299
+ <div id="resultsSection" class="hidden">
1300
+ <!-- Export Buttons -->
1301
+ <div class="card">
1302
+ <div class="card-header">
1303
+ <h2 class="card-title"><i class="fas fa-chart-bar"></i> Analysis Results</h2>
1304
+ <div class="results-actions">
1305
+ <button class="action-button secondary-action" id="exportCsvBtn">
1306
+ <i class="fas fa-file-csv"></i> CSV
1307
+ </button>
1308
+ <button class="action-button secondary-action" id="exportPdfBtn">
1309
+ <i class="fas fa-file-pdf"></i> PDF
1310
+ </button>
1311
+ <button class="action-button secondary-action" id="exportJsonBtn">
1312
+ <i class="fas fa-file-code"></i> JSON
1313
+ </button>
1314
+ <button class="action-button secondary-action" id="newAnalysisBtn">
1315
+ <i class="fas fa-redo"></i> New
1316
+ </button>
1317
+ </div>
1318
+ </div>
1319
+
1320
+ <!-- Results Summary -->
1321
+ <div class="results-summary" id="resultsSummary">
1322
+ <!-- Summary cards will be populated here -->
1323
+ </div>
1324
+
1325
+ <!-- Results Table -->
1326
+ <div class="results-table-container">
1327
+ <table class="results-table" id="resultsTable">
1328
+ <thead>
1329
+ <tr>
1330
+ <th>Image</th>
1331
+ <th>Status</th>
1332
+ <th>Score</th>
1333
+ <th>Signals</th>
1334
+ <th>Details</th>
1335
+ </tr>
1336
+ </thead>
1337
+ <tbody id="resultsTableBody">
1338
+ <!-- Results will be populated here -->
1339
+ <tr id="noResultsRow">
1340
+ <td colspan="5" class="text-center" style="padding: 3rem; color: var(--text-light);">
1341
+ <i class="fas fa-chart-bar" style="font-size: 3rem; margin-bottom: 1rem; opacity: 0.5;"></i>
1342
+ <p>No analysis results yet. Upload images and click "Start Analysis" to begin.</p>
1343
+ </td>
1344
+ </tr>
1345
+ </tbody>
1346
+ </table>
1347
+ </div>
1348
+ </div>
1349
+
1350
+ <!-- Detailed Analysis -->
1351
+ <div class="detailed-analysis">
1352
+ <div class="analysis-header" id="toggleDetailedAnalysis">
1353
+ <h3><i class="fas fa-search"></i> Detailed Analysis</h3>
1354
+ <i class="fas fa-chevron-down" id="detailedAnalysisIcon"></i>
1355
+ </div>
1356
+ <div class="analysis-content" id="detailedAnalysisContent">
1357
+ <!-- Detailed analysis will be populated here -->
1358
+ <p id="noDetailedAnalysis" class="text-center" style="color: var(--text-light); padding: 2rem;">
1359
+ <i class="fas fa-eye" style="font-size: 2rem; margin-bottom: 1rem; opacity: 0.5;"></i><br>
1360
+ Select an image to view detailed analysis
1361
+ </p>
1362
+ </div>
1363
+ </div>
1364
+ </div>
1365
+ </div>
1366
+ </div>
1367
+
1368
+ <!-- Footer with reduced spacing -->
1369
+ <footer>
1370
+ <div class="container">
1371
+ <div class="footer-links">
1372
+ <a href="#" class="footer-link">Documentation</a>
1373
+ <a href="#" class="footer-link">API Reference</a>
1374
+ <a href="#" class="footer-link">Privacy</a>
1375
+ <a href="#" class="footer-link">Support</a>
1376
+ </div>
1377
+ <p>AI Image Screener v1.0.0 © 2025</p>
1378
+ </div>
1379
+ </footer>
1380
+
1381
+ <script>
1382
+ // API Configuration
1383
+ const API_BASE_URL = window.location.origin;
1384
+ const BATCH_ENDPOINT = '/analyze/batch';
1385
+ const HEALTH_ENDPOINT = '/health';
1386
+ const BATCH_PROGRESS_ENDPOINT = '/batch';
1387
+ const CSV_REPORT_ENDPOINT = '/report/csv';
1388
+ const PDF_REPORT_ENDPOINT = '/report/pdf';
1389
+
1390
+ // Global state
1391
+ let files = [];
1392
+ let fileDataUrls = {};
1393
+ let currentBatchId = null;
1394
+ let batchResults = null;
1395
+ let pollingInterval = null;
1396
+ let selectedImageIndex = null;
1397
+
1398
+ // DOM Elements
1399
+ const landingScreen = document.getElementById('landingScreen');
1400
+ const analysisScreen = document.getElementById('analysisScreen');
1401
+ const resultsSection = document.getElementById('resultsSection');
1402
+ const loadingOverlay = document.getElementById('loadingOverlay');
1403
+ const toast = document.getElementById('toast');
1404
+ const tryNowBtn = document.getElementById('tryNowBtn');
1405
+ const backHomeBtn = document.getElementById('backHomeBtn');
1406
+ const newAnalysisBtn = document.getElementById('newAnalysisBtn');
1407
+ const uploadArea = document.getElementById('uploadArea');
1408
+ const fileInput = document.getElementById('fileInput');
1409
+ const fileInputBtn = document.getElementById('fileInputBtn');
1410
+ const thumbnailGrid = document.getElementById('thumbnailGrid');
1411
+ const analyzeBtn = document.getElementById('analyzeBtn');
1412
+ const analyzeButtonContainer = document.getElementById('analyzeButtonContainer');
1413
+ const progressContainer = document.getElementById('progressContainer');
1414
+ const progressFill = document.getElementById('progressFill');
1415
+ const progressPercent = document.getElementById('progressPercent');
1416
+ const currentFile = document.getElementById('currentFile');
1417
+ const progressStats = document.getElementById('progressStats');
1418
+ const resultsSummary = document.getElementById('resultsSummary');
1419
+ const resultsTableBody = document.getElementById('resultsTableBody');
1420
+ const noResultsRow = document.getElementById('noResultsRow');
1421
+ const exportCsvBtn = document.getElementById('exportCsvBtn');
1422
+ const exportPdfBtn = document.getElementById('exportPdfBtn');
1423
+ const exportJsonBtn = document.getElementById('exportJsonBtn');
1424
+ const toggleDetailedAnalysis = document.getElementById('toggleDetailedAnalysis');
1425
+ const detailedAnalysisIcon = document.getElementById('detailedAnalysisIcon');
1426
+ const detailedAnalysisContent = document.getElementById('detailedAnalysisContent');
1427
+ const noDetailedAnalysis = document.getElementById('noDetailedAnalysis');
1428
+ const tabButtons = document.querySelectorAll('.tab-button');
1429
+ const tabContents = document.querySelectorAll('.tab-content');
1430
+
1431
+ // Initialize
1432
+ document.addEventListener('DOMContentLoaded', () => {
1433
+ setupEventListeners();
1434
+ setupTabs();
1435
+ checkApiHealth();
1436
+ });
1437
+
1438
+ // Toast notification
1439
+ function showToast(message, type = 'success') {
1440
+ toast.textContent = message;
1441
+ toast.className = `toast ${type} show`;
1442
+
1443
+ setTimeout(() => {
1444
+ toast.classList.remove('show');
1445
+ }, 3000);
1446
+ }
1447
+
1448
+ // Loading overlay
1449
+ function showLoading(show) {
1450
+ if (show) {
1451
+ loadingOverlay.classList.add('active');
1452
+ } else {
1453
+ loadingOverlay.classList.remove('active');
1454
+ }
1455
+ }
1456
+
1457
+ // Tab functionality
1458
+ function setupTabs() {
1459
+ tabButtons.forEach(button => {
1460
+ button.addEventListener('click', () => {
1461
+ const tabId = button.dataset.tab + 'Tab';
1462
+
1463
+ // Remove active class from all buttons and contents
1464
+ tabButtons.forEach(btn => btn.classList.remove('active'));
1465
+ tabContents.forEach(content => content.classList.remove('active'));
1466
+
1467
+ // Add active class to clicked button and corresponding content
1468
+ button.classList.add('active');
1469
+ document.getElementById(tabId).classList.add('active');
1470
+ });
1471
+ });
1472
+ }
1473
+
1474
+ // Setup event listeners - FIXED FOR ONE-CLICK UPLOAD
1475
+ function setupEventListeners() {
1476
+ // Navigation
1477
+ tryNowBtn.addEventListener('click', showAnalysisScreen);
1478
+ backHomeBtn.addEventListener('click', showLandingScreen);
1479
+ newAnalysisBtn.addEventListener('click', resetAnalysis);
1480
+
1481
+ // File upload - ONLY ONE CLICK HANDLER
1482
+ fileInputBtn.addEventListener('click', (e) => {
1483
+ e.stopPropagation(); // Prevent bubbling
1484
+ fileInput.click();
1485
+ });
1486
+
1487
+ // File input change handler
1488
+ fileInput.addEventListener('change', handleFileSelect);
1489
+
1490
+ // Remove the uploadArea click handler that was causing double triggers
1491
+ // Keep only drag and drop handlers for uploadArea
1492
+ uploadArea.addEventListener('dragover', handleDragOver);
1493
+ uploadArea.addEventListener('dragleave', handleDragLeave);
1494
+ uploadArea.addEventListener('drop', handleDrop);
1495
+
1496
+ // Analysis
1497
+ analyzeBtn.addEventListener('click', startAnalysis);
1498
+
1499
+ // Export
1500
+ exportCsvBtn.addEventListener('click', exportCsv);
1501
+ exportPdfBtn.addEventListener('click', exportPdf);
1502
+ exportJsonBtn.addEventListener('click', exportJson);
1503
+
1504
+ // Detailed analysis toggle
1505
+ toggleDetailedAnalysis.addEventListener('click', () => {
1506
+ detailedAnalysisContent.classList.toggle('show');
1507
+ detailedAnalysisIcon.classList.toggle('fa-chevron-down');
1508
+ detailedAnalysisIcon.classList.toggle('fa-chevron-up');
1509
+ });
1510
+ }
1511
+
1512
+ // Screen navigation
1513
+ function showLandingScreen() {
1514
+ landingScreen.classList.remove('hidden');
1515
+ analysisScreen.classList.add('hidden');
1516
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1517
+ }
1518
+
1519
+ function showAnalysisScreen() {
1520
+ landingScreen.classList.add('hidden');
1521
+ analysisScreen.classList.remove('hidden');
1522
+ window.scrollTo({ top: 0, behavior: 'smooth' });
1523
+ }
1524
+
1525
+ // File handling
1526
+ function handleDragOver(e) {
1527
+ e.preventDefault();
1528
+ uploadArea.classList.add('dragover');
1529
+ }
1530
+
1531
+ function handleDragLeave(e) {
1532
+ e.preventDefault();
1533
+ uploadArea.classList.remove('dragover');
1534
+ }
1535
+
1536
+ function handleDrop(e) {
1537
+ e.preventDefault();
1538
+ uploadArea.classList.remove('dragover');
1539
+
1540
+ const droppedFiles = Array.from(e.dataTransfer.files);
1541
+ if (droppedFiles.length > 0) {
1542
+ processFiles(droppedFiles);
1543
+ }
1544
+ }
1545
+
1546
+ function handleFileSelect(e) {
1547
+ const selectedFiles = Array.from(e.target.files);
1548
+ if (selectedFiles.length > 0) {
1549
+ processFiles(selectedFiles);
1550
+ }
1551
+ // Clear the input value to allow same file selection
1552
+ e.target.value = '';
1553
+ }
1554
+
1555
+ async function processFiles(newFiles) {
1556
+ const validFiles = [];
1557
+
1558
+ for (const file of newFiles) {
1559
+ const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
1560
+ const maxSize = 10 * 1024 * 1024;
1561
+
1562
+ if (!validTypes.includes(file.type)) {
1563
+ showToast(`File ${file.name} is not a supported image type.`, 'error');
1564
+ continue;
1565
+ }
1566
+
1567
+ if (file.size > maxSize) {
1568
+ showToast(`File ${file.name} exceeds the 10MB size limit.`, 'error');
1569
+ continue;
1570
+ }
1571
+
1572
+ validFiles.push(file);
1573
+ }
1574
+
1575
+ if (validFiles.length > 0) {
1576
+ showLoading(true);
1577
+
1578
+ try {
1579
+ // Generate thumbnails
1580
+ for (const file of validFiles) {
1581
+ try {
1582
+ const dataUrl = await createThumbnail(file);
1583
+ fileDataUrls[file.name] = dataUrl;
1584
+ } catch (error) {
1585
+ console.error('Failed to create thumbnail:', error);
1586
+ fileDataUrls[file.name] = null;
1587
+ }
1588
+ }
1589
+
1590
+ files.push(...validFiles);
1591
+ updateThumbnailGrid();
1592
+ showToast(`Added ${validFiles.length} file(s)`, 'success');
1593
+ } catch (error) {
1594
+ console.error('Error processing files:', error);
1595
+ showToast('Error processing files. Please try again.', 'error');
1596
+ } finally {
1597
+ showLoading(false);
1598
+ }
1599
+ }
1600
+ }
1601
+
1602
+ function createThumbnail(file) {
1603
+ return new Promise((resolve, reject) => {
1604
+ const reader = new FileReader();
1605
+ reader.onload = (e) => {
1606
+ const img = new Image();
1607
+ img.onload = () => {
1608
+ const canvas = document.createElement('canvas');
1609
+ const ctx = canvas.getContext('2d');
1610
+
1611
+ // Set canvas dimensions for thumbnail
1612
+ const maxSize = 120;
1613
+ let width = img.width;
1614
+ let height = img.height;
1615
+
1616
+ if (width > height) {
1617
+ if (width > maxSize) {
1618
+ height *= maxSize / width;
1619
+ width = maxSize;
1620
+ }
1621
+ } else {
1622
+ if (height > maxSize) {
1623
+ width *= maxSize / height;
1624
+ height = maxSize;
1625
+ }
1626
+ }
1627
+
1628
+ canvas.width = width;
1629
+ canvas.height = height;
1630
+ ctx.drawImage(img, 0, 0, width, height);
1631
+
1632
+ resolve(canvas.toDataURL('image/jpeg', 0.7));
1633
+ };
1634
+ img.onerror = reject;
1635
+ img.src = e.target.result;
1636
+ };
1637
+ reader.onerror = reject;
1638
+ reader.readAsDataURL(file);
1639
+ });
1640
+ }
1641
+
1642
+ function updateThumbnailGrid() {
1643
+ thumbnailGrid.innerHTML = '';
1644
+
1645
+ if (files.length === 0) {
1646
+ thumbnailGrid.style.display = 'none';
1647
+ analyzeButtonContainer.style.display = 'none';
1648
+ return;
1649
+ }
1650
+
1651
+ thumbnailGrid.style.display = 'grid';
1652
+ analyzeButtonContainer.style.display = 'block';
1653
+
1654
+ files.forEach((file, index) => {
1655
+ const thumbnailItem = document.createElement('div');
1656
+ thumbnailItem.className = 'thumbnail-item';
1657
+ thumbnailItem.dataset.index = index;
1658
+
1659
+ const dataUrl = fileDataUrls[file.name] || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="%23f0f0f0"/><text x="50" y="50" font-family="Arial" font-size="14" text-anchor="middle" fill="%23999">No preview</text></svg>';
1660
+
1661
+ thumbnailItem.innerHTML = `
1662
+ <img src="${dataUrl}" alt="${file.name}" class="thumbnail-img">
1663
+ <div class="thumbnail-overlay">
1664
+ <span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 80px;">
1665
+ ${file.name}
1666
+ </span>
1667
+ <button class="remove-thumbnail" data-index="${index}">
1668
+ <i class="fas fa-times"></i>
1669
+ </button>
1670
+ </div>
1671
+ `;
1672
+
1673
+ thumbnailGrid.appendChild(thumbnailItem);
1674
+ });
1675
+
1676
+ // Add event listeners to remove buttons
1677
+ document.querySelectorAll('.remove-thumbnail').forEach(btn => {
1678
+ btn.addEventListener('click', (e) => {
1679
+ e.stopPropagation();
1680
+ const index = parseInt(e.currentTarget.dataset.index);
1681
+ removeFile(index);
1682
+ });
1683
+ });
1684
+ }
1685
+
1686
+ function removeFile(index) {
1687
+ const removedFile = files[index].name;
1688
+ files.splice(index, 1);
1689
+ delete fileDataUrls[removedFile];
1690
+ updateThumbnailGrid();
1691
+ showToast(`Removed ${removedFile}`, 'warning');
1692
+ }
1693
+
1694
+ // Analysis
1695
+ async function startAnalysis() {
1696
+ if (files.length === 0) return;
1697
+
1698
+ showLoading(true);
1699
+ analyzeBtn.disabled = true;
1700
+ analyzeBtn.innerHTML = '<span class="spinner"></span> Processing...';
1701
+
1702
+ progressFill.style.width = '0%';
1703
+ progressPercent.textContent = '0%';
1704
+ currentFile.textContent = 'Starting analysis...';
1705
+ progressStats.textContent = `0 / ${files.length}`;
1706
+ progressContainer.classList.remove('hidden');
1707
+
1708
+ clearResults();
1709
+
1710
+ const formData = new FormData();
1711
+ files.forEach(file => {
1712
+ formData.append('files', file);
1713
+ });
1714
+
1715
+ try {
1716
+ console.log('Sending batch request for', files.length, 'images...');
1717
+
1718
+ const response = await fetch(BATCH_ENDPOINT, {
1719
+ method: 'POST',
1720
+ body: formData
1721
+ });
1722
+
1723
+ console.log('Response status:', response.status);
1724
+
1725
+ if (!response.ok) {
1726
+ const errorText = await response.text();
1727
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
1728
+ }
1729
+
1730
+ const apiResponse = await response.json();
1731
+ console.log('API response:', apiResponse);
1732
+
1733
+ showLoading(false);
1734
+
1735
+ if (!apiResponse.success) {
1736
+ throw new Error(apiResponse.message || 'API request failed');
1737
+ }
1738
+
1739
+ const data = apiResponse.data;
1740
+ console.log('Data:', data);
1741
+
1742
+ if (data && data.batch_id) {
1743
+ console.log('Polling mode: batch_id =', data.batch_id);
1744
+ currentBatchId = data.batch_id;
1745
+ showToast('Analysis started. Processing in background...', 'success');
1746
+ startPollingProgress();
1747
+
1748
+ } else if (data && data.result) {
1749
+ console.log('Immediate results mode');
1750
+
1751
+ progressFill.style.width = '100%';
1752
+ progressPercent.textContent = '100%';
1753
+ currentFile.textContent = 'Processing complete!';
1754
+ progressStats.textContent = `${files.length} / ${files.length}`;
1755
+
1756
+ batchResults = data.result;
1757
+
1758
+ setTimeout(() => {
1759
+ displayResults();
1760
+ resetUI();
1761
+
1762
+ resultsSection.classList.remove('hidden');
1763
+ document.getElementById('resultsSection').scrollIntoView({
1764
+ behavior: 'smooth',
1765
+ block: 'start'
1766
+ });
1767
+
1768
+ showToast(`Analysis complete! Processed ${files.length} image(s)`, 'success');
1769
+ }, 500);
1770
+
1771
+ } else {
1772
+ console.error('Unexpected response format:', apiResponse);
1773
+ throw new Error('Invalid response format from server');
1774
+ }
1775
+
1776
+ } catch (error) {
1777
+ console.error('Analysis failed:', error);
1778
+ showLoading(false);
1779
+ showToast('Analysis failed: ' + error.message, 'error');
1780
+ resetUI();
1781
+ }
1782
+ }
1783
+
1784
+ function startPollingProgress() {
1785
+ if (pollingInterval) clearInterval(pollingInterval);
1786
+
1787
+ pollingInterval = setInterval(async () => {
1788
+ try {
1789
+ const response = await fetch(`${BATCH_PROGRESS_ENDPOINT}/${currentBatchId}/progress`);
1790
+ const data = await response.json();
1791
+
1792
+ const sessionData = data.data || data;
1793
+
1794
+ if (sessionData.status === 'completed') {
1795
+ clearInterval(pollingInterval);
1796
+
1797
+ if (sessionData.result) {
1798
+ batchResults = sessionData.result;
1799
+ } else {
1800
+ batchResults = sessionData;
1801
+ }
1802
+
1803
+ displayResults();
1804
+ resetUI();
1805
+
1806
+ resultsSection.classList.remove('hidden');
1807
+ document.getElementById('resultsSection').scrollIntoView({
1808
+ behavior: 'smooth',
1809
+ block: 'start'
1810
+ });
1811
+
1812
+ showToast('Batch analysis completed!', 'success');
1813
+
1814
+ } else if (sessionData.status === 'processing') {
1815
+ const progress = sessionData.progress;
1816
+ if (progress) {
1817
+ const percent = Math.round((progress.current / progress.total) * 100);
1818
+
1819
+ progressFill.style.width = `${percent}%`;
1820
+ progressPercent.textContent = `${percent}%`;
1821
+ currentFile.textContent = progress.filename || 'Processing...';
1822
+ progressStats.textContent = `${progress.current} / ${progress.total}`;
1823
+ }
1824
+ } else if (sessionData.status === 'failed' || sessionData.status === 'interrupted') {
1825
+ clearInterval(pollingInterval);
1826
+ showToast(`Analysis failed: ${sessionData.error || 'Unknown error'}`, 'error');
1827
+ resetUI();
1828
+ }
1829
+ } catch (error) {
1830
+ console.error('Progress polling failed:', error);
1831
+ }
1832
+ }, 1000);
1833
+ }
1834
+
1835
+ function displayResults() {
1836
+ if (!batchResults) {
1837
+ console.error('No results to display:', batchResults);
1838
+ return;
1839
+ }
1840
+
1841
+ console.log('Displaying batch results:', batchResults);
1842
+
1843
+ const results = batchResults.results || [];
1844
+ console.log('Results array:', results);
1845
+
1846
+ updateSummary(batchResults);
1847
+
1848
+ resultsTableBody.innerHTML = '';
1849
+
1850
+ results.forEach((result, index) => {
1851
+ const row = document.createElement('tr');
1852
+ row.dataset.index = index;
1853
+
1854
+ const resultData = result;
1855
+
1856
+ const filename = resultData.filename || 'Unknown';
1857
+ const overallScore = resultData.overall_score || 0;
1858
+ const status = resultData.status || 'LIKELY_AUTHENTIC';
1859
+ const confidence = resultData.confidence || 0;
1860
+ const imageSize = resultData.image_size || [0, 0];
1861
+ const signals = resultData.signals || [];
1862
+ const processingTime = resultData.processing_time || 0;
1863
+
1864
+ const scorePercent = Math.round(overallScore * 100);
1865
+ let scoreClass = 'score-low';
1866
+ let scoreWidth = '30%';
1867
+ if (scorePercent >= 70) {
1868
+ scoreClass = 'score-high';
1869
+ scoreWidth = '90%';
1870
+ } else if (scorePercent >= 50) {
1871
+ scoreClass = 'score-medium';
1872
+ scoreWidth = '60%';
1873
+ }
1874
+
1875
+ const flaggedCount = signals.filter(s => s.status === 'flagged').length;
1876
+ const warningCount = signals.filter(s => s.status === 'warning').length;
1877
+
1878
+ // Format status for display (remove underscores)
1879
+ const displayStatus = status.replace(/_/g, ' ');
1880
+
1881
+ // Get thumbnail
1882
+ const thumbnailSrc = fileDataUrls[filename] || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><rect width="40" height="40" fill="%23f0f0f0"/></svg>';
1883
+
1884
+ row.innerHTML = `
1885
+ <td style="min-width: 200px;">
1886
+ <div style="display: flex; align-items: center; gap: 0.75rem;">
1887
+ <img src="${thumbnailSrc}" alt="${filename}" style="width: 40px; height: 40px; object-fit: cover; border-radius: 0.25rem; border: 1px solid var(--border);">
1888
+ <div>
1889
+ <div style="font-weight: 500; font-size: 0.875rem;">${filename}</div>
1890
+ <div style="font-size: 0.75rem; color: var(--text-light);">
1891
+ ${imageSize[0]} × ${imageSize[1]}
1892
+ </div>
1893
+ </div>
1894
+ </div>
1895
+ </td>
1896
+ <td>
1897
+ <span class="status-badge ${status === 'LIKELY_AUTHENTIC' ? 'status-authentic' : 'status-review'}" style="white-space: nowrap;">
1898
+ ${displayStatus}
1899
+ </span>
1900
+ </td>
1901
+ <td>
1902
+ <div class="score-indicator">
1903
+ <span style="min-width: 40px; font-size: 0.875rem;">${scorePercent}%</span>
1904
+ <div class="score-bar">
1905
+ <div class="score-fill ${scoreClass}" style="width: ${scoreWidth}"></div>
1906
+ </div>
1907
+ </div>
1908
+ </td>
1909
+ <td style="min-width: 150px;">
1910
+ <div style="display: flex; gap: 0.25rem; flex-wrap: wrap;">
1911
+ ${flaggedCount > 0 ? `<span class="signal-badge signal-flagged" style="font-size: 0.7rem;">${flaggedCount} flagged</span>` : ''}
1912
+ ${warningCount > 0 ? `<span class="signal-badge signal-warning" style="font-size: 0.7rem;">${warningCount} warning</span>` : ''}
1913
+ ${signals.length - flaggedCount - warningCount > 0 ?
1914
+ `<span class="signal-badge signal-passed" style="font-size: 0.7rem;">${signals.length - flaggedCount - warningCount} passed</span>` : ''}
1915
+ </div>
1916
+ </td>
1917
+ <td>
1918
+ <button class="action-button secondary-action view-detail-btn" data-index="${index}" title="View Details" style="padding: 0.25rem 0.5rem;">
1919
+ <i class="fas fa-eye"></i>
1920
+ </button>
1921
+ </td>
1922
+ `;
1923
+
1924
+ resultsTableBody.appendChild(row);
1925
+ });
1926
+
1927
+ noResultsRow.classList.add('hidden');
1928
+
1929
+ document.querySelectorAll('.view-detail-btn').forEach(btn => {
1930
+ btn.addEventListener('click', (e) => {
1931
+ e.stopPropagation();
1932
+ const index = parseInt(e.currentTarget.dataset.index);
1933
+ showDetailedAnalysis(index);
1934
+ });
1935
+ });
1936
+
1937
+ document.querySelectorAll('#resultsTableBody tr').forEach(row => {
1938
+ row.addEventListener('click', (e) => {
1939
+ if (!e.target.closest('.view-detail-btn')) {
1940
+ const index = parseInt(row.dataset.index);
1941
+ showDetailedAnalysis(index);
1942
+ }
1943
+ });
1944
+ });
1945
+ }
1946
+
1947
+ function updateSummary(batchResult) {
1948
+ const total = batchResult.total_images || 0;
1949
+ const processed = batchResult.processed || batchResult.results?.length || 0;
1950
+ const failed = batchResult.failed || 0;
1951
+
1952
+ let likelyAuthentic = 0;
1953
+ let reviewRequired = 0;
1954
+
1955
+ if (batchResult.results) {
1956
+ batchResult.results.forEach(result => {
1957
+ const resultData = result;
1958
+ const status = resultData.status || 'LIKELY_AUTHENTIC';
1959
+ if (status === 'LIKELY_AUTHENTIC') {
1960
+ likelyAuthentic++;
1961
+ } else if (status === 'REVIEW_REQUIRED') {
1962
+ reviewRequired++;
1963
+ }
1964
+ });
1965
+ }
1966
+
1967
+ resultsSummary.innerHTML = `
1968
+ <div class="summary-card">
1969
+ <div class="summary-value">${processed}</div>
1970
+ <div class="summary-label">Total Processed</div>
1971
+ </div>
1972
+ <div class="summary-card">
1973
+ <div class="summary-value">${likelyAuthentic}</div>
1974
+ <div class="summary-label">Likely Authentic</div>
1975
+ </div>
1976
+ <div class="summary-card">
1977
+ <div class="summary-value">${reviewRequired}</div>
1978
+ <div class="summary-label">Review Required</div>
1979
+ </div>
1980
+ <div class="summary-card">
1981
+ <div class="summary-value">${failed}</div>
1982
+ <div class="summary-label">Failed</div>
1983
+ </div>
1984
+ `;
1985
+ }
1986
+
1987
+ function showDetailedAnalysis(index) {
1988
+ if (!batchResults || !batchResults.results || !batchResults.results[index]) return;
1989
+
1990
+ selectedImageIndex = index;
1991
+ const result = batchResults.results[index];
1992
+ const resultData = result;
1993
+
1994
+ const filename = resultData.filename || 'Unknown';
1995
+ const overallScore = resultData.overall_score || 0;
1996
+ const status = resultData.status || 'LIKELY_AUTHENTIC';
1997
+ const confidence = resultData.confidence || 0;
1998
+ const imageSize = resultData.image_size || [0, 0];
1999
+ const processingTime = resultData.processing_time || 0;
2000
+ const signals = resultData.signals || [];
2001
+
2002
+ const scorePercent = Math.round(overallScore * 100);
2003
+ const displayStatus = status.replace(/_/g, ' ');
2004
+
2005
+ // Ensure detailed analysis is expanded
2006
+ detailedAnalysisContent.classList.add('show');
2007
+ detailedAnalysisIcon.classList.remove('fa-chevron-down');
2008
+ detailedAnalysisIcon.classList.add('fa-chevron-up');
2009
+
2010
+ document.getElementById('detailedAnalysisContent').scrollIntoView({
2011
+ behavior: 'smooth',
2012
+ block: 'start'
2013
+ });
2014
+
2015
+ // Build signals HTML
2016
+ let signalsHtml = '';
2017
+ if (signals && signals.length > 0) {
2018
+ signals.forEach(signal => {
2019
+ let statusClass = 'signal-passed';
2020
+ if (signal.status === 'warning') statusClass = 'signal-warning';
2021
+ if (signal.status === 'flagged') statusClass = 'signal-flagged';
2022
+
2023
+ const signalScore = Math.round((signal.score || 0) * 100);
2024
+
2025
+ signalsHtml += `
2026
+ <div class="signal-card">
2027
+ <div class="signal-header">
2028
+ <strong>${signal.name || 'Unknown Metric'}</strong>
2029
+ <span class="signal-badge ${statusClass}">${signal.status}</span>
2030
+ </div>
2031
+ <p style="font-size: 0.875rem; margin-bottom: 0.5rem; color: var(--text-light);">
2032
+ ${signal.explanation || 'No explanation available.'}
2033
+ </p>
2034
+ <div style="display: flex; justify-content: space-between; align-items: center;">
2035
+ <div style="font-size: 0.75rem; color: var(--text-light);">
2036
+ Score: ${signalScore}%
2037
+ </div>
2038
+ </div>
2039
+ </div>
2040
+ `;
2041
+ });
2042
+ } else {
2043
+ signalsHtml = '<p class="text-center" style="color: var(--text-light);">No detection signals available.</p>';
2044
+ }
2045
+
2046
+ detailedAnalysisContent.innerHTML = `
2047
+ <div style="margin-bottom: 1.5rem;">
2048
+ <div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
2049
+ <img src="${fileDataUrls[filename] || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60" viewBox="0 0 60 60"><rect width="60" height="60" fill="%23f0f0f0"/></svg>'}"
2050
+ alt="${filename}"
2051
+ style="width: 60px; height: 60px; object-fit: cover; border-radius: 0.5rem; border: 1px solid var(--border);">
2052
+ <div>
2053
+ <h4 style="margin-bottom: 0.25rem;">${filename}</h4>
2054
+ <div style="font-size: 0.875rem; color: var(--text-light);">
2055
+ ${imageSize[0]} × ${imageSize[1]} • ${processingTime.toFixed(2)}s
2056
+ </div>
2057
+ </div>
2058
+ </div>
2059
+
2060
+ <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
2061
+ <div style="text-align: center; padding: 1rem; background-color: #f8fafc; border-radius: 0.5rem;">
2062
+ <div style="font-size: 1.5rem; font-weight: 700; color: ${scorePercent >= 70 ? '#e53e3e' : scorePercent >= 50 ? '#d69e2e' : '#38a169'};">${scorePercent}%</div>
2063
+ <div style="font-size: 0.875rem; color: var(--text-light);">Score</div>
2064
+ </div>
2065
+ <div style="text-align: center; padding: 1rem; background-color: #f8fafc; border-radius: 0.5rem;">
2066
+ <div style="font-size: 1.5rem; font-weight: 700; color: ${displayStatus.includes('REVIEW') ? '#d69e2e' : '#38a169'};">${displayStatus}</div>
2067
+ <div style="font-size: 0.875rem; color: var(--text-light);">Verdict</div>
2068
+ </div>
2069
+ <div style="text-align: center; padding: 1rem; background-color: #f8fafc; border-radius: 0.5rem;">
2070
+ <div style="font-size: 1.5rem; font-weight: 700;">${confidence}%</div>
2071
+ <div style="font-size: 0.875rem; color: var(--text-light);">Confidence</div>
2072
+ </div>
2073
+ </div>
2074
+ </div>
2075
+
2076
+ <h4 style="margin-bottom: 1rem;">Detection Signals</h4>
2077
+ <div class="signal-grid">
2078
+ ${signalsHtml}
2079
+ </div>
2080
+
2081
+ <div class="signal-card" style="margin-top: 1.5rem; background-color: ${displayStatus.includes('REVIEW') ? 'rgba(214, 158, 46, 0.1)' : 'rgba(56, 161, 105, 0.1)'}; border-color: ${displayStatus.includes('REVIEW') ? 'rgba(214, 158, 46, 0.3)' : 'rgba(56, 161, 105, 0.3)'};">
2082
+ <div class="signal-header">
2083
+ <strong>Recommendation</strong>
2084
+ </div>
2085
+ <p style="margin-bottom: 0.5rem;">
2086
+ ${displayStatus.includes('REVIEW') ? 'Manual verification recommended' : 'No immediate action required'}
2087
+ </p>
2088
+ <div style="font-size: 0.875rem; color: var(--text-light);">
2089
+ Confidence: ${confidence}% likelihood of ${displayStatus.includes('REVIEW') ? 'AI generation' : 'authenticity'}
2090
+ </div>
2091
+ </div>
2092
+ `;
2093
+ }
2094
+
2095
+ // Export functions
2096
+ async function exportCsv() {
2097
+ if (!currentBatchId) {
2098
+ showToast('No analysis results to export.', 'warning');
2099
+ return;
2100
+ }
2101
+
2102
+ showLoading(true);
2103
+ try {
2104
+ // Using GET request since backend now accepts both GET and POST
2105
+ const response = await fetch(`${CSV_REPORT_ENDPOINT}/${currentBatchId}`);
2106
+
2107
+ if (response.ok) {
2108
+ // Get the blob data
2109
+ const blob = await response.blob();
2110
+
2111
+ // Create download link
2112
+ const downloadLink = document.createElement('a');
2113
+ downloadLink.href = URL.createObjectURL(blob);
2114
+ downloadLink.download = `ImageScreenAI_Report_${currentBatchId}.csv`;
2115
+
2116
+ document.body.appendChild(downloadLink);
2117
+ downloadLink.click();
2118
+ document.body.removeChild(downloadLink);
2119
+
2120
+ showToast('CSV report downloaded successfully.', 'success');
2121
+ } else {
2122
+ showToast('Failed to generate CSV report.', 'error');
2123
+ }
2124
+ } catch (error) {
2125
+ console.error('CSV export failed:', error);
2126
+ showToast('CSV export failed. Please try again.', 'error');
2127
+ } finally {
2128
+ showLoading(false);
2129
+ }
2130
+ }
2131
+
2132
+ async function exportPdf() {
2133
+ if (!currentBatchId) {
2134
+ showToast('No analysis results to export.', 'warning');
2135
+ return;
2136
+ }
2137
+
2138
+ showLoading(true);
2139
+ try {
2140
+ // Using GET request since backend now accepts both GET and POST
2141
+ const response = await fetch(`${PDF_REPORT_ENDPOINT}/${currentBatchId}`);
2142
+
2143
+ if (response.ok) {
2144
+ // Get the blob data
2145
+ const blob = await response.blob();
2146
+
2147
+ // Create download link
2148
+ const downloadLink = document.createElement('a');
2149
+ downloadLink.href = URL.createObjectURL(blob);
2150
+ downloadLink.download = `ImageScreenAI_Report_${currentBatchId}.pdf`;
2151
+
2152
+ document.body.appendChild(downloadLink);
2153
+ downloadLink.click();
2154
+ document.body.removeChild(downloadLink);
2155
+
2156
+ showToast('PDF report downloaded successfully.', 'success');
2157
+ } else {
2158
+ showToast('Failed to generate PDF report.', 'error');
2159
+ }
2160
+ } catch (error) {
2161
+ console.error('PDF export failed:', error);
2162
+ showToast('PDF export failed. Please try again.', 'error');
2163
+ } finally {
2164
+ showLoading(false);
2165
+ }
2166
+ }
2167
+
2168
+ async function exportJson() {
2169
+ if (!batchResults) {
2170
+ showToast('No analysis results to export.', 'warning');
2171
+ return;
2172
+ }
2173
+
2174
+ showLoading(true);
2175
+ try {
2176
+ const dataStr = JSON.stringify(batchResults, null, 2);
2177
+ const dataBlob = new Blob([dataStr], {type: 'application/json'});
2178
+
2179
+ const downloadLink = document.createElement('a');
2180
+ downloadLink.href = URL.createObjectURL(dataBlob);
2181
+ downloadLink.download = `ImageScreenAI_Report_${new Date().toISOString().split('T')[0]}_${currentBatchId || 'report'}.json`;
2182
+
2183
+ document.body.appendChild(downloadLink);
2184
+ downloadLink.click();
2185
+ document.body.removeChild(downloadLink);
2186
+
2187
+ showToast('JSON report downloaded successfully.', 'success');
2188
+ } catch (error) {
2189
+ console.error('JSON export failed:', error);
2190
+ showToast('JSON export failed. Please try again.', 'error');
2191
+ } finally {
2192
+ showLoading(false);
2193
+ }
2194
+ }
2195
+
2196
+ // Reset functions
2197
+ function resetUI() {
2198
+ analyzeBtn.disabled = false;
2199
+ analyzeBtn.innerHTML = '<div class="btn-content"><i class="fas fa-play"></i> Start Analysis</div>';
2200
+
2201
+ setTimeout(() => {
2202
+ progressContainer.classList.add('hidden');
2203
+ }, 2000);
2204
+ }
2205
+
2206
+ function resetAnalysis() {
2207
+ files = [];
2208
+ fileDataUrls = {};
2209
+ batchResults = null;
2210
+ currentBatchId = null;
2211
+ selectedImageIndex = null;
2212
+
2213
+ updateThumbnailGrid();
2214
+ clearResults();
2215
+ resultsSection.classList.add('hidden');
2216
+ detailedAnalysisContent.innerHTML = '<p id="noDetailedAnalysis" class="text-center" style="color: var(--text-light); padding: 2rem;"><i class="fas fa-eye" style="font-size: 2rem; margin-bottom: 1rem; opacity: 0.5;"></i><br>Select an image to view detailed analysis</p>';
2217
+
2218
+ window.scrollTo({ top: 0, behavior: 'smooth' });
2219
+ showToast('Analysis reset. Ready for new upload.', 'success');
2220
+ }
2221
+
2222
+ function clearResults() {
2223
+ resultsSummary.innerHTML = '';
2224
+ resultsTableBody.innerHTML = '';
2225
+ noResultsRow.classList.remove('hidden');
2226
+
2227
+ if (pollingInterval) {
2228
+ clearInterval(pollingInterval);
2229
+ pollingInterval = null;
2230
+ }
2231
+ }
2232
+
2233
+ // API health check
2234
+ async function checkApiHealth() {
2235
+ try {
2236
+ const response = await fetch(HEALTH_ENDPOINT);
2237
+ const data = await response.json();
2238
+
2239
+ if (data.status === 'ok') {
2240
+ console.log('API connected successfully');
2241
+ }
2242
+ } catch (error) {
2243
+ console.error('API health check failed:', error);
2244
+ }
2245
+ }
2246
+ </script>
2247
+ </body>
2248
+ </html>
utils/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .logger import get_logger
2
+ from .image_processor import ImageProcessor
3
+ from .validators import ImageValidator
4
+ from .helpers import (
5
+ generate_unique_id,
6
+ cleanup_old_files,
7
+ format_filesize,
8
+ calculate_hash
9
+ )
10
+
11
+ __all__ = [
12
+ 'get_logger',
13
+ 'ImageProcessor',
14
+ 'ImageValidator',
15
+ 'generate_unique_id',
16
+ 'cleanup_old_files',
17
+ 'format_filesize',
18
+ 'calculate_hash'
19
+ ]
utils/helpers.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import re
3
+ import uuid
4
+ import hashlib
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+ from datetime import timedelta
8
+ from utils.logger import get_logger
9
+
10
+
11
+ # Setup Logging
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ def generate_unique_id() -> str:
16
+ """
17
+ Generate unique ID for files/reports
18
+ """
19
+ unique_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}"
20
+
21
+ return unique_id
22
+
23
+
24
+ def calculate_hash(file_path: Path) -> str:
25
+ """
26
+ Calculate SHA256 hash of file
27
+ """
28
+ sha256 = hashlib.sha256()
29
+
30
+ with open(file_path, 'rb') as f:
31
+ for chunk in iter(lambda: f.read(8192), b''):
32
+ sha256.update(chunk)
33
+
34
+ hash = sha256.hexdigest()
35
+
36
+ return hash
37
+
38
+
39
+ def format_filesize(size_bytes: int) -> str:
40
+ """
41
+ Format file size in human-readable format
42
+ """
43
+ for unit in ['B', 'KB', 'MB', 'GB']:
44
+ if (size_bytes < 1024.0):
45
+ return f"{size_bytes:.2f} {unit}"
46
+
47
+ size_bytes /= 1024.0
48
+
49
+ file_size = f"{size_bytes:.2f} TB"
50
+
51
+ return file_size
52
+
53
+
54
+ def cleanup_old_files(directory: Path, days: int = 7) -> int:
55
+ """
56
+ Clean up files older than specified days
57
+
58
+ Arguments:
59
+ ----------
60
+ directory { Path } : Directory to clean
61
+
62
+ days { int } : Files older than this will be deleted
63
+
64
+ Returns:
65
+ --------
66
+ { int } : Number of files deleted
67
+ """
68
+ if not directory.exists():
69
+ return 0
70
+
71
+ cutoff = datetime.now() - timedelta(days = days)
72
+ deleted = 0
73
+
74
+ for file_path in directory.iterdir():
75
+ if file_path.is_file():
76
+ file_time = datetime.fromtimestamp(file_path.stat().st_mtime)
77
+
78
+ if (file_time < cutoff):
79
+ try:
80
+ file_path.unlink()
81
+ deleted += 1
82
+ logger.debug(f"Deleted old file: {file_path.name}")
83
+
84
+ except Exception as e:
85
+ logger.error(f"Failed to delete {file_path.name}: {e}")
86
+
87
+ if (deleted > 0):
88
+ logger.info(f"Cleaned up {deleted} files from {directory.name}")
89
+
90
+ return deleted
91
+
92
+
93
+ def safe_filename(filename: str) -> str:
94
+ """
95
+ Sanitize filename for safe storage
96
+ """
97
+ # Remove any path components
98
+ filename = Path(filename).name
99
+
100
+ # Replace unsafe characters
101
+ filename = re.sub(r'[^\w\s.-]', '', filename)
102
+
103
+ # Limit length
104
+ if (len(filename) > 255):
105
+ name, ext = filename.rsplit('.', 1) if '.' in filename else (filename, '')
106
+ filename = name[:250] + ('.' + ext if ext else '')
107
+
108
+ return filename
utils/image_processor.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import cv2
3
+ import numpy as np
4
+ from PIL import Image
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+ from typing import Optional
8
+ from utils.logger import get_logger
9
+ from config.constants import LUMINANCE_WEIGHTS
10
+
11
+
12
+ # Setup Logging
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class ImageProcessor:
17
+ """
18
+ Image loading and preprocessing utilities
19
+ """
20
+ @staticmethod
21
+ def load_image(file_path: Path) -> np.ndarray:
22
+ """
23
+ Load image as numpy array in RGB format
24
+
25
+ Arguments:
26
+ ----------
27
+ file_path { Path } : Path of the image file needs to be loaded
28
+
29
+ Returns:
30
+ --------
31
+ { np.ndarray } : Image array in RGB format (H, W, 3)
32
+ """
33
+ try:
34
+ image = cv2.imread(str(file_path))
35
+
36
+ if image is None:
37
+ raise ValueError(f"Failed to load image: {file_path}")
38
+
39
+ # Convert BGR to RGB
40
+ image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
41
+
42
+ logger.debug(f"Loaded image: {file_path.name} shape={image.shape}")
43
+ return image
44
+
45
+ except Exception as e:
46
+ logger.error(f"Error loading image {file_path}: {e}")
47
+ raise
48
+
49
+
50
+ @staticmethod
51
+ def rgb_to_luminance(image: np.ndarray) -> np.ndarray:
52
+ """
53
+ Convert RGB image to luminance using ITU-R BT.709 standard
54
+
55
+ Arguments:
56
+ ----------
57
+ image { np.ndarray } : RGB image array (H, W, 3)
58
+
59
+ Returns:
60
+ --------
61
+ { np.ndarray } : Luminance array (H, W)
62
+ """
63
+ if ((image.ndim != 3) or (image.shape[2] != 3)):
64
+ raise ValueError(f"Expected RGB image (H, W, 3), got shape {image.shape}")
65
+
66
+ r, g, b = LUMINANCE_WEIGHTS
67
+
68
+ luminance = r * image[:, :, 0] + g * image[:, :, 1] + b * image[:, :, 2]
69
+
70
+ return luminance.astype(np.float32)
71
+
72
+
73
+ @staticmethod
74
+ def compute_gradients(luminance: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
75
+ """
76
+ Compute Sobel gradients
77
+
78
+ Arguments:
79
+ ----------
80
+ luminance { np.ndarray } : Luminance array (H, W)
81
+
82
+ Returns:
83
+ --------
84
+ { tuple } : Tuple of (gradient_x, gradient_y)
85
+ """
86
+ gx = cv2.Sobel(luminance, cv2.CV_64F, 1, 0, ksize = 3)
87
+ gy = cv2.Sobel(luminance, cv2.CV_64F, 0, 1, ksize = 3)
88
+
89
+ return gx, gy
90
+
91
+
92
+ @staticmethod
93
+ def normalize_image(image: np.ndarray) -> np.ndarray:
94
+ """
95
+ Normalize image to [0, 1] range
96
+ """
97
+ normalized_image = image.astype(np.float32) / 255.0
98
+
99
+ return normalized_image
100
+
101
+
102
+ @staticmethod
103
+ def resize_if_needed(image: np.ndarray, max_dimension: int = 2048) -> np.ndarray:
104
+ """
105
+ Resize image if larger than max_dimension while maintaining aspect ratio
106
+
107
+ Arguments:
108
+ ----------
109
+ image { np.ndarray } : Input image
110
+
111
+ max_dimension { int } : Maximum dimension (width or height)
112
+
113
+ Returns:
114
+ --------
115
+ { np.ndarray } : Resized image if needed, otherwise original
116
+ """
117
+ h, w = image.shape[:2]
118
+
119
+ if (max(h, w) <= max_dimension):
120
+ return image
121
+
122
+ scale = max_dimension / max(h, w)
123
+ new_w = int(w * scale)
124
+ new_h = int(h * scale)
125
+
126
+ resized = cv2.resize(image, (new_w, new_h), interpolation = cv2.INTER_AREA)
127
+
128
+ logger.debug(f"Resized image from {w}x{h} to {new_w}x{new_h}")
129
+
130
+ return resized
131
+
132
+ @staticmethod
133
+ def extract_patches(image: np.ndarray, patch_size: int, stride: int, max_patches: Optional[int] = None) -> np.ndarray:
134
+ """
135
+ Extract patches from image
136
+
137
+ Arguments:
138
+ ----------
139
+ image { np.ndarray } : Input image (H, W) or (H, W, C)
140
+
141
+ patch_size { int } : Size of patches
142
+
143
+ stride { int } : Stride between patches
144
+
145
+ max_patches { int } : Maximum number of patches to extract
146
+
147
+ Returns:
148
+ --------
149
+ { np.ndarray } : Array of patches
150
+ """
151
+ h, w = image.shape[:2]
152
+ patches = list()
153
+
154
+ for y in range(0, h - patch_size + 1, stride):
155
+ for x in range(0, w - patch_size + 1, stride):
156
+ patch = image[y:y+patch_size, x:x+patch_size]
157
+
158
+ patches.append(patch)
159
+
160
+ if (max_patches and (len(patches) >= max_patches)):
161
+ return np.array(patches)
162
+
163
+ return np.array(patches)
utils/logger.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import sys
3
+ import logging
4
+ from datetime import datetime
5
+ from config.settings import settings
6
+
7
+
8
+ class ColoredFormatter(logging.Formatter):
9
+ """
10
+ Colored log formatter for better readability
11
+ """
12
+ COLORS = {'DEBUG' : '\033[36m', # Cyan
13
+ 'INFO' : '\033[32m', # Green
14
+ 'WARNING' : '\033[33m', # Yellow
15
+ 'ERROR' : '\033[31m', # Red
16
+ 'CRITICAL' : '\033[35m', # Magenta
17
+ 'RESET' : '\033[0m',
18
+ }
19
+
20
+
21
+ def format(self, record):
22
+ if sys.stdout.isatty():
23
+ levelname = record.levelname
24
+
25
+ if (levelname in self.COLORS):
26
+ record.levelname = f"{self.COLORS[levelname]}{levelname}{self.COLORS['RESET']}"
27
+
28
+ return super().format(record)
29
+
30
+
31
+ def setup_logger(name: str = None) -> logging.Logger:
32
+ """
33
+ Setup logger with console and file handlers
34
+
35
+ Arguments:
36
+ ----------
37
+ name { str } : Logger name (defaults to root logger)
38
+
39
+ Returns:
40
+ --------
41
+ { logging.Logger } : Configured logger instance
42
+ """
43
+ logger = logging.getLogger(name or settings.APP_NAME)
44
+
45
+ # Avoid duplicate handlers
46
+ if logger.handlers:
47
+ return logger
48
+
49
+ level = getattr(logging, settings.LOG_LEVEL, logging.INFO)
50
+ logger.setLevel(level)
51
+
52
+ logger.propagate = False
53
+
54
+ # Console handler with colors
55
+ console_handler = logging.StreamHandler(sys.stdout)
56
+ console_handler.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
57
+
58
+ console_formatter = ColoredFormatter('%(asctime)s | %(levelname)-8s | %(name)s | %(message)s',
59
+ datefmt = '%Y-%m-%d %H:%M:%S'
60
+ )
61
+ console_handler.setFormatter(console_formatter)
62
+
63
+ logger.addHandler(console_handler)
64
+
65
+ # File handler
66
+ log_file = settings.LOGS_DIR / f"app_{datetime.now().strftime('%Y%m%d')}.log"
67
+ file_handler = logging.FileHandler(log_file)
68
+ file_handler.setLevel(logging.DEBUG)
69
+
70
+ file_formatter = logging.Formatter('%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s',
71
+ datefmt = '%Y-%m-%d %H:%M:%S'
72
+ )
73
+
74
+ file_handler.setFormatter(file_formatter)
75
+
76
+ logger.addHandler(file_handler)
77
+
78
+ return logger
79
+
80
+
81
+ def get_logger(name: str = None) -> logging.Logger:
82
+ """
83
+ Get or create logger instance
84
+ """
85
+ return setup_logger(name)
utils/validators.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ import magic
3
+ from PIL import Image
4
+ from pathlib import Path
5
+ from typing import Tuple
6
+ from utils.logger import get_logger
7
+ from config.settings import settings
8
+ from config.constants import MIN_IMAGE_DIMENSION
9
+ from config.constants import MAX_IMAGE_DIMENSION
10
+
11
+
12
+ # Setup Logging
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class ValidationError(Exception):
17
+ """
18
+ Custom validation error
19
+ """
20
+ pass
21
+
22
+
23
+ class ImageValidator:
24
+ """
25
+ Validate uploaded images
26
+ """
27
+ @staticmethod
28
+ def validate_file_size(file_size: int) -> None:
29
+ """
30
+ Validate file size
31
+ """
32
+ if (file_size > settings.max_file_size_bytes):
33
+ raise ValidationError(f"File size {file_size} bytes exceeds maximum {settings.max_file_size_bytes} bytes")
34
+
35
+ if (file_size == 0):
36
+ raise ValidationError("File is empty")
37
+
38
+
39
+ @staticmethod
40
+ def validate_file_extension(filename: str) -> None:
41
+ """
42
+ Validate file extension
43
+ """
44
+ extension = Path(filename).suffix.lower()
45
+
46
+ if (extension not in settings.ALLOWED_EXTENSIONS):
47
+ raise ValidationError(f"File extension {extension} not allowed. Allowed: {', '.join(settings.ALLOWED_EXTENSIONS)}")
48
+
49
+
50
+ @staticmethod
51
+ def validate_image_content(file_path: Path) -> Tuple[int, int]:
52
+ """
53
+ Validate image can be opened and get dimensions
54
+ """
55
+ try:
56
+ with Image.open(file_path) as image:
57
+ width, height = image.size
58
+
59
+ # Validate dimensions
60
+ if ((width < MIN_IMAGE_DIMENSION) or (height < MIN_IMAGE_DIMENSION)):
61
+ raise ValidationError(f"Image dimensions ({width}x{height}) too small. Minimum: {MIN_IMAGE_DIMENSION}px")
62
+
63
+ if ((width > MAX_IMAGE_DIMENSION) or (height > MAX_IMAGE_DIMENSION)):
64
+ raise ValidationError(f"Image dimensions ({width}x{height}) too large. Maximum: {MAX_IMAGE_DIMENSION}px")
65
+
66
+ # Verify format
67
+ if (image.format.lower() not in ['jpeg', 'png', 'webp']):
68
+ raise ValidationError(f"Unsupported image format: {image.format}")
69
+
70
+ return width, height
71
+
72
+ except ValidationError:
73
+ raise
74
+
75
+ except Exception as e:
76
+ raise ValidationError(f"Cannot open image: {str(e)}")
77
+
78
+
79
+ @staticmethod
80
+ def validate_mime_type(file_path: Path) -> None:
81
+ """
82
+ Validate MIME type matches image
83
+ """
84
+ try:
85
+ mime = magic.from_file(str(file_path), mime = True)
86
+
87
+ if (not mime.startswith('image/')):
88
+ raise ValidationError(f"File is not an image. MIME type: {mime}")
89
+
90
+ except Exception as e:
91
+ logger.warning(f"MIME type validation failed: {e}")
92
+ # Don't fail if python-magic is not available
93
+
94
+
95
+ @classmethod
96
+ def validate_image(cls, file_path: Path, filename: str, file_size: int) -> Tuple[int, int]:
97
+ """
98
+ Comprehensive image validation
99
+ """
100
+ cls.validate_file_size(file_size)
101
+ cls.validate_file_extension(filename)
102
+
103
+ dimensions = cls.validate_image_content(file_path)
104
+ cls.validate_mime_type(file_path) # Optional, commented out if python-magic not available
105
+
106
+ logger.debug(f"Validated image: {filename} ({dimensions[0]}x{dimensions[1]})")
107
+
108
+ return dimensions