Spaces:
Running
Running
Commit
·
e7f1d57
0
Parent(s):
Initial commit: ImageScreenAI statistical image screening system
Browse files- .env.example +59 -0
- .gitignore +22 -0
- Dockerfile +58 -0
- README.md +420 -0
- app.py +347 -0
- config/__init__.py +0 -0
- config/constants.py +325 -0
- config/schemas.py +112 -0
- config/settings.py +107 -0
- docs/API_DOCUMENTATION.md +711 -0
- docs/ARCHITECTURE.md +516 -0
- features/__init__.py +0 -0
- features/batch_processor.py +299 -0
- features/detailed_result_maker.py +481 -0
- features/threshold_manager.py +277 -0
- metrics/__init__.py +0 -0
- metrics/aggregator.py +288 -0
- metrics/color_analyzer.py +352 -0
- metrics/frequency_analyzer.py +260 -0
- metrics/gradient_field_pca.py +236 -0
- metrics/noise_analyzer.py +335 -0
- metrics/texture_analyzer.py +308 -0
- reporter/__init__.py +0 -0
- reporter/csv_reporter.py +462 -0
- reporter/json_reporter.py +349 -0
- reporter/pdf_reporter.py +1050 -0
- requirements.txt +71 -0
- ui/index.html +2248 -0
- utils/__init__.py +19 -0
- utils/helpers.py +108 -0
- utils/image_processor.py +163 -0
- utils/logger.py +85 -0
- utils/validators.py +108 -0
.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 |
+
[](https://www.python.org/downloads/)
|
| 21 |
+
[](https://fastapi.tiangolo.com/)
|
| 22 |
+
[](LICENSE)
|
| 23 |
+
[](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
|