Spaces:
Sleeping
Sleeping
Commit Β·
4ad15a3
0
Parent(s):
feat: Add full implementation and HF Space artifacts
Browse files- .gitattributes +3 -0
- .gitignore +41 -0
- Documents/Project_Report_Digital_Image_Forgery_Detector.docx +3 -0
- Image_Forgery_Detection_Colab_1.ipynb +3 -0
- M3_best.keras +3 -0
- README.md +26 -0
- app.py +143 -0
- packages.txt +8 -0
- requirements.txt +7 -0
- train.py +172 -0
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.keras filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.ipynb filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.docx filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Local IDE / Claude Code ---
|
| 2 |
+
.claude/
|
| 3 |
+
.vscode/
|
| 4 |
+
.idea/
|
| 5 |
+
|
| 6 |
+
# --- HTML build artefact (rebuilt by pandoc; not source) ---
|
| 7 |
+
Documents/header_inline.html
|
| 8 |
+
|
| 9 |
+
# --- Future: data files (see PROJECT_SCOPE.md Β§5.2 + Β§6) ---
|
| 10 |
+
data/raw/
|
| 11 |
+
data/processed/
|
| 12 |
+
|
| 13 |
+
# --- Future: training outputs (weights live on HuggingFace Hub) ---
|
| 14 |
+
checkpoints/
|
| 15 |
+
logs/
|
| 16 |
+
|
| 17 |
+
# --- Python ---
|
| 18 |
+
__pycache__/
|
| 19 |
+
*.py[cod]
|
| 20 |
+
*$py.class
|
| 21 |
+
*.so
|
| 22 |
+
.Python
|
| 23 |
+
.venv/
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.pytest_cache/
|
| 28 |
+
.ruff_cache/
|
| 29 |
+
|
| 30 |
+
# --- Jupyter ---
|
| 31 |
+
.ipynb_checkpoints/
|
| 32 |
+
|
| 33 |
+
# --- OS ---
|
| 34 |
+
.DS_Store
|
| 35 |
+
Thumbs.db
|
| 36 |
+
|
| 37 |
+
# --- Secrets / credentials ---
|
| 38 |
+
.env
|
| 39 |
+
.env.local
|
| 40 |
+
*.pem
|
| 41 |
+
kaggle.json
|
Documents/Project_Report_Digital_Image_Forgery_Detector.docx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:7744b5d6ae0dbb487a8093db92798e1b4732788f6a4ac713ee4e0c4bc7fe9a6c
|
| 3 |
+
size 23701
|
Image_Forgery_Detection_Colab_1.ipynb
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:565d9e04f3f6789367bcbcc038973f344e6ba9e1cc1b9c55d73d31a8dd92e409
|
| 3 |
+
size 37939
|
M3_best.keras
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b121144d425497c30e6d1a2a85acd6c5240069d642a9d8121567763bac0ec957
|
| 3 |
+
size 102889096
|
README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Image Forgery Detector
|
| 3 |
+
emoji: π‘οΈ
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: red
|
| 6 |
+
sdk: streamlit
|
| 7 |
+
sdk_version: 1.35.0
|
| 8 |
+
python_version: 3.11
|
| 9 |
+
app_file: app.py
|
| 10 |
+
pinned: false
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# Image Forgery Detector
|
| 14 |
+
|
| 15 |
+
This application detects tampering in images using a Dual-Branch CNN architecture.
|
| 16 |
+
|
| 17 |
+
## How it works:
|
| 18 |
+
1. **RGB Branch:** Uses a pretrained ResNet50 to extract semantic features from the original image.
|
| 19 |
+
2. **ELA Branch:** Computes Error Level Analysis (ELA) to detect JPEG compression inconsistencies.
|
| 20 |
+
3. **Fused Model:** Combines features from both branches to make a final prediction.
|
| 21 |
+
|
| 22 |
+
## Explainability:
|
| 23 |
+
The app uses **Grad-CAM** to visualize which parts of the image the model focused on when making its decision.
|
| 24 |
+
|
| 25 |
+
## Deployment:
|
| 26 |
+
This app is designed to be deployed on Hugging Face Spaces using Streamlit.
|
app.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import numpy as np
|
| 3 |
+
import tensorflow as tf
|
| 4 |
+
import cv2
|
| 5 |
+
import io
|
| 6 |
+
from PIL import Image, ImageChops
|
| 7 |
+
from tensorflow.keras import models
|
| 8 |
+
|
| 9 |
+
# ββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 10 |
+
IMG_SIZE = (224, 224)
|
| 11 |
+
ELA_QUALITY = 90
|
| 12 |
+
ELA_SCALE = 15
|
| 13 |
+
|
| 14 |
+
# ββ Forensic Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 15 |
+
def compute_ela(original, quality=ELA_QUALITY, scale=ELA_SCALE):
|
| 16 |
+
original = original.convert('RGB')
|
| 17 |
+
buf = io.BytesIO()
|
| 18 |
+
original.save(buf, 'JPEG', quality=quality)
|
| 19 |
+
buf.seek(0)
|
| 20 |
+
compressed = Image.open(buf)
|
| 21 |
+
|
| 22 |
+
ela_image = ImageChops.difference(original, compressed)
|
| 23 |
+
ela_image = ImageChops.multiply(
|
| 24 |
+
ela_image, Image.new('RGB', ela_image.size, (scale, scale, scale))
|
| 25 |
+
)
|
| 26 |
+
return ela_image
|
| 27 |
+
|
| 28 |
+
def get_gradcam(model, input_data):
|
| 29 |
+
# Dynamically find the last conv layer
|
| 30 |
+
last_conv_layer_name = None
|
| 31 |
+
for layer in reversed(model.layers):
|
| 32 |
+
if 'conv2d' in layer.name:
|
| 33 |
+
last_conv_layer_name = layer.name
|
| 34 |
+
break
|
| 35 |
+
|
| 36 |
+
if not last_conv_layer_name:
|
| 37 |
+
# Fallback to any layer with conv in name
|
| 38 |
+
for layer in reversed(model.layers):
|
| 39 |
+
if 'conv' in layer.name:
|
| 40 |
+
last_conv_layer_name = layer.name
|
| 41 |
+
break
|
| 42 |
+
|
| 43 |
+
grad_model = models.Model(
|
| 44 |
+
inputs=model.inputs,
|
| 45 |
+
outputs=[model.get_layer(last_conv_layer_name).output, model.output]
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
with tf.GradientTape() as tape:
|
| 49 |
+
last_conv_out, preds = grad_model(input_data)
|
| 50 |
+
class_channel = preds[:, 0]
|
| 51 |
+
|
| 52 |
+
grads = tape.gradient(class_channel, last_conv_out)
|
| 53 |
+
pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2))
|
| 54 |
+
heatmap = last_conv_out[0] @ pooled_grads[..., tf.newaxis]
|
| 55 |
+
|
| 56 |
+
max_val = tf.math.reduce_max(heatmap)
|
| 57 |
+
if max_val == 0:
|
| 58 |
+
max_val = 1e-10
|
| 59 |
+
heatmap = tf.squeeze(tf.maximum(heatmap, 0) / max_val).numpy()
|
| 60 |
+
return heatmap
|
| 61 |
+
|
| 62 |
+
@st.cache_resource
|
| 63 |
+
def load_trained_model():
|
| 64 |
+
return models.load_model('M3_best.keras')
|
| 65 |
+
|
| 66 |
+
# ββ Main UI ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 67 |
+
st.set_page_config(page_title="Image Forgery Detector", layout="wide")
|
| 68 |
+
|
| 69 |
+
st.title("π‘οΈ Image Forgery Detector")
|
| 70 |
+
st.markdown("""
|
| 71 |
+
Detect tampering in images using a Dual-Branch CNN (RGB + ELA).
|
| 72 |
+
Upload an image to see if it's Authentic or Forged.
|
| 73 |
+
""")
|
| 74 |
+
|
| 75 |
+
uploaded_file = st.file_uploader("Choose an image...", type=["jpg", "jpeg", "png", "tif"])
|
| 76 |
+
|
| 77 |
+
if uploaded_file is not None:
|
| 78 |
+
image = Image.open(uploaded_file).convert('RGB')
|
| 79 |
+
|
| 80 |
+
col1, col2 = st.columns(2)
|
| 81 |
+
with col1:
|
| 82 |
+
st.image(image, caption="Original Image", use_column_width=True)
|
| 83 |
+
|
| 84 |
+
with st.spinner("Analyzing..."):
|
| 85 |
+
# Load model
|
| 86 |
+
m3 = load_trained_model()
|
| 87 |
+
|
| 88 |
+
# Prepare inputs
|
| 89 |
+
rgb_in = np.array(image.resize(IMG_SIZE)).astype(np.float32)[np.newaxis, ...]
|
| 90 |
+
ela_img = compute_ela(image)
|
| 91 |
+
ela_in = np.array(ela_img.resize(IMG_SIZE)).astype(np.float32)[np.newaxis, ...]
|
| 92 |
+
|
| 93 |
+
# Handle input mapping
|
| 94 |
+
try:
|
| 95 |
+
if hasattr(m3, 'input_names') and m3.input_names:
|
| 96 |
+
input_data = {name: tensor for name, tensor in zip(m3.input_names, [rgb_in, ela_in])}
|
| 97 |
+
else:
|
| 98 |
+
input_data = [rgb_in, ela_in]
|
| 99 |
+
except:
|
| 100 |
+
input_data = [rgb_in, ela_in]
|
| 101 |
+
|
| 102 |
+
# Inference
|
| 103 |
+
pred = m3.predict(input_data, verbose=0)[0][0]
|
| 104 |
+
label = "FORGED" if pred > 0.5 else "AUTHENTIC"
|
| 105 |
+
confidence = pred if pred > 0.5 else 1 - pred
|
| 106 |
+
|
| 107 |
+
if 0.45 <= pred <= 0.55:
|
| 108 |
+
label = "UNCERTAIN"
|
| 109 |
+
|
| 110 |
+
with col2:
|
| 111 |
+
st.subheader("Prediction Result")
|
| 112 |
+
color = "red" if label == "FORGED" else "green" if label == "AUTHENTIC" else "orange"
|
| 113 |
+
st.markdown(f"### Result: <span style='color:{color}'>{label}</span>", unsafe_allow_html=True)
|
| 114 |
+
st.write(f"**Confidence:** {confidence:.2%}")
|
| 115 |
+
|
| 116 |
+
st.progress(float(confidence))
|
| 117 |
+
|
| 118 |
+
st.divider()
|
| 119 |
+
|
| 120 |
+
col3, col4 = st.columns(2)
|
| 121 |
+
with col3:
|
| 122 |
+
st.subheader("ELA Artifacts")
|
| 123 |
+
st.image(ela_img, caption="Error Level Analysis (JPEG inconsistencies)", use_column_width=True)
|
| 124 |
+
st.info("ELA highlights regions with different compression levels, often indicating tampered areas.")
|
| 125 |
+
|
| 126 |
+
with col4:
|
| 127 |
+
st.subheader("Grad-CAM Explainability")
|
| 128 |
+
try:
|
| 129 |
+
heatmap = get_gradcam(m3, input_data)
|
| 130 |
+
heatmap_color = cv2.applyColorMap(np.uint8(255 * heatmap), cv2.COLORMAP_JET)
|
| 131 |
+
heatmap_color = cv2.cvtColor(heatmap_color, cv2.COLOR_BGR2RGB)
|
| 132 |
+
heatmap_resized = cv2.resize(heatmap_color, (image.size[0], image.size[1]))
|
| 133 |
+
|
| 134 |
+
# Blend
|
| 135 |
+
img_np = np.array(image)
|
| 136 |
+
overlay = np.uint8(heatmap_resized * 0.4 + img_np * 0.6)
|
| 137 |
+
st.image(overlay, caption="Model Focus Regions", use_column_width=True)
|
| 138 |
+
st.info("The heatmap shows which parts of the image the model focused on to make its decision.")
|
| 139 |
+
except Exception as e:
|
| 140 |
+
st.error(f"Could not generate Grad-CAM: {e}")
|
| 141 |
+
|
| 142 |
+
else:
|
| 143 |
+
st.info("Please upload an image to start detection.")
|
packages.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
git
|
| 2 |
+
git-lfs
|
| 3 |
+
ffmpeg
|
| 4 |
+
libsm6
|
| 5 |
+
libxext6
|
| 6 |
+
cmake
|
| 7 |
+
rsync
|
| 8 |
+
libgl1
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
tensorflow
|
| 3 |
+
opencv-python-headless
|
| 4 |
+
pillow
|
| 5 |
+
numpy
|
| 6 |
+
scikit-learn
|
| 7 |
+
matplotlib
|
train.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import io
|
| 4 |
+
import random
|
| 5 |
+
import numpy as np
|
| 6 |
+
import tensorflow as tf
|
| 7 |
+
import cv2
|
| 8 |
+
from PIL import Image, ImageChops, ImageDraw
|
| 9 |
+
from sklearn.model_selection import train_test_split
|
| 10 |
+
from tensorflow.keras import layers, models, applications
|
| 11 |
+
|
| 12 |
+
# ββ Global configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
+
SEED = 42
|
| 14 |
+
IMG_SIZE = (224, 224)
|
| 15 |
+
ELA_QUALITY = 90
|
| 16 |
+
ELA_SCALE = 15
|
| 17 |
+
BATCH_SIZE = 32
|
| 18 |
+
EPOCHS = 5
|
| 19 |
+
TARGET_DIR = "./casia_v2"
|
| 20 |
+
|
| 21 |
+
def set_reproducibility(seed=SEED):
|
| 22 |
+
tf.random.set_seed(seed)
|
| 23 |
+
np.random.seed(seed)
|
| 24 |
+
random.seed(seed)
|
| 25 |
+
os.environ['PYTHONHASHSEED'] = str(seed)
|
| 26 |
+
|
| 27 |
+
set_reproducibility()
|
| 28 |
+
|
| 29 |
+
def generate_robust_dataset(num_samples=120):
|
| 30 |
+
if os.path.exists(TARGET_DIR):
|
| 31 |
+
import shutil
|
| 32 |
+
shutil.rmtree(TARGET_DIR)
|
| 33 |
+
os.makedirs(TARGET_DIR)
|
| 34 |
+
|
| 35 |
+
print(f"Generating {num_samples} synthetic samples...")
|
| 36 |
+
for i in range(num_samples):
|
| 37 |
+
img_data = np.random.randint(100, 200, (256, 256, 3), dtype=np.uint8)
|
| 38 |
+
img = Image.fromarray(img_data)
|
| 39 |
+
|
| 40 |
+
is_forged = i >= (num_samples // 2)
|
| 41 |
+
if not is_forged:
|
| 42 |
+
filename = f"Au_arc_000{i:02d}.jpg"
|
| 43 |
+
else:
|
| 44 |
+
draw = ImageDraw.Draw(img)
|
| 45 |
+
draw.rectangle([50, 50, 150, 150], fill=(255, 0, 0))
|
| 46 |
+
filename = f"Tp_s_N_arc_000{i:02d}_00099_001.jpg"
|
| 47 |
+
|
| 48 |
+
img.save(os.path.join(TARGET_DIR, filename))
|
| 49 |
+
|
| 50 |
+
def compute_ela(image_path_or_pil, quality=ELA_QUALITY, scale=ELA_SCALE):
|
| 51 |
+
if isinstance(image_path_or_pil, str):
|
| 52 |
+
original = Image.open(image_path_or_pil).convert('RGB')
|
| 53 |
+
else:
|
| 54 |
+
original = image_path_or_pil.convert('RGB')
|
| 55 |
+
|
| 56 |
+
buf = io.BytesIO()
|
| 57 |
+
original.save(buf, 'JPEG', quality=quality)
|
| 58 |
+
buf.seek(0)
|
| 59 |
+
compressed = Image.open(buf)
|
| 60 |
+
|
| 61 |
+
ela_image = ImageChops.difference(original, compressed)
|
| 62 |
+
ela_image = ImageChops.multiply(
|
| 63 |
+
ela_image, Image.new('RGB', ela_image.size, (scale, scale, scale))
|
| 64 |
+
)
|
| 65 |
+
return ela_image
|
| 66 |
+
|
| 67 |
+
class CASIAParser:
|
| 68 |
+
@staticmethod
|
| 69 |
+
def get_ids(filename):
|
| 70 |
+
name = os.path.basename(filename)
|
| 71 |
+
if name.startswith('Au_'):
|
| 72 |
+
match = re.search(r'Au_[a-z]{3}_(\d+)', name)
|
| 73 |
+
return [match.group(1)] if match else []
|
| 74 |
+
elif name.startswith('Tp_'):
|
| 75 |
+
parts = name.split('_')
|
| 76 |
+
return [parts[4], parts[5]] if len(parts) >= 6 else []
|
| 77 |
+
return []
|
| 78 |
+
|
| 79 |
+
def split_dataset(data_dir, train_ratio=0.8, val_ratio=0.1, test_ratio=0.1):
|
| 80 |
+
all_images = [
|
| 81 |
+
os.path.join(data_dir, f)
|
| 82 |
+
for f in os.listdir(data_dir)
|
| 83 |
+
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.tif'))
|
| 84 |
+
]
|
| 85 |
+
unique_ids = sorted({i for p in all_images for i in CASIAParser.get_ids(p)})
|
| 86 |
+
if not unique_ids:
|
| 87 |
+
unique_ids = [str(i) for i in range(len(all_images))]
|
| 88 |
+
tr_ids, temp = train_test_split(unique_ids, train_size=train_ratio, random_state=SEED)
|
| 89 |
+
v_ids, _ = train_test_split(temp, train_size=val_ratio / (val_ratio + test_ratio), random_state=SEED)
|
| 90 |
+
tr_ids, v_ids = set(tr_ids), set(v_ids)
|
| 91 |
+
splits = {'train': [], 'val': [], 'test': []}
|
| 92 |
+
for p in all_images:
|
| 93 |
+
ids = CASIAParser.get_ids(p)
|
| 94 |
+
if not ids:
|
| 95 |
+
splits['train'].append(p) if random.random() < 0.8 else splits['test'].append(p)
|
| 96 |
+
continue
|
| 97 |
+
if any(i in tr_ids for i in ids): splits['train'].append(p)
|
| 98 |
+
elif any(i in v_ids for i in ids): splits['val'].append(p)
|
| 99 |
+
else: splits['test'].append(p)
|
| 100 |
+
return splits
|
| 101 |
+
|
| 102 |
+
def preload_images(paths, img_size=IMG_SIZE):
|
| 103 |
+
rgb_list, ela_list, label_list = [], [], []
|
| 104 |
+
for p in paths:
|
| 105 |
+
pil_img = Image.open(p).convert('RGB')
|
| 106 |
+
rgb_list.append(np.array(pil_img.resize(img_size), dtype=np.float32))
|
| 107 |
+
ela_list.append(np.array(compute_ela(pil_img).resize(img_size), dtype=np.float32))
|
| 108 |
+
label_list.append(1 if os.path.basename(p).startswith('Tp_') else 0)
|
| 109 |
+
return np.array(rgb_list), np.array(ela_list), np.array(label_list)
|
| 110 |
+
|
| 111 |
+
def make_dataset(rgb_arr, ela_arr, labels, batch_size=BATCH_SIZE, shuffle=False, repeat=True):
|
| 112 |
+
ds = tf.data.Dataset.from_tensor_slices(((rgb_arr, ela_arr), labels))
|
| 113 |
+
if shuffle:
|
| 114 |
+
ds = ds.shuffle(buffer_size=len(labels), seed=SEED, reshuffle_each_iteration=True)
|
| 115 |
+
ds = ds.batch(batch_size, drop_remainder=False)
|
| 116 |
+
if repeat:
|
| 117 |
+
ds = ds.repeat()
|
| 118 |
+
return ds.prefetch(tf.data.AUTOTUNE)
|
| 119 |
+
|
| 120 |
+
def get_rgb_branch():
|
| 121 |
+
base = applications.ResNet50(
|
| 122 |
+
include_top=False, weights='imagenet', input_shape=(*IMG_SIZE, 3)
|
| 123 |
+
)
|
| 124 |
+
base.trainable = False
|
| 125 |
+
inputs = layers.Input(shape=(*IMG_SIZE, 3))
|
| 126 |
+
x = applications.resnet50.preprocess_input(inputs)
|
| 127 |
+
x = base(x, training=False)
|
| 128 |
+
return inputs, layers.GlobalAveragePooling2D()(x)
|
| 129 |
+
|
| 130 |
+
def get_ela_branch():
|
| 131 |
+
inputs = layers.Input(shape=(*IMG_SIZE, 3))
|
| 132 |
+
x = layers.Rescaling(1. / 255)(inputs)
|
| 133 |
+
for filters in [32, 64, 128]:
|
| 134 |
+
x = layers.Conv2D(filters, (3, 3), activation='relu', padding='same')(x)
|
| 135 |
+
x = layers.BatchNormalization()(x)
|
| 136 |
+
x = layers.MaxPooling2D((2, 2))(x)
|
| 137 |
+
return inputs, layers.GlobalAveragePooling2D()(x)
|
| 138 |
+
|
| 139 |
+
def build_model():
|
| 140 |
+
rgb_in, rgb_f = get_rgb_branch()
|
| 141 |
+
ela_in, ela_f = get_ela_branch()
|
| 142 |
+
fused = layers.Concatenate()([rgb_f, ela_f])
|
| 143 |
+
out = layers.Dense(1, activation='sigmoid')(
|
| 144 |
+
layers.Dropout(0.5)(layers.Dense(256, activation='relu')(fused))
|
| 145 |
+
)
|
| 146 |
+
return models.Model(inputs=[rgb_in, ela_in], outputs=out)
|
| 147 |
+
|
| 148 |
+
if __name__ == "__main__":
|
| 149 |
+
generate_robust_dataset(120)
|
| 150 |
+
splits = split_dataset(TARGET_DIR)
|
| 151 |
+
train_rgb, train_ela, train_labels = preload_images(splits['train'])
|
| 152 |
+
val_rgb, val_ela, val_labels = preload_images(splits['val'])
|
| 153 |
+
|
| 154 |
+
train_ds = make_dataset(train_rgb, train_ela, train_labels, shuffle=True)
|
| 155 |
+
val_ds = make_dataset(val_rgb, val_ela, val_labels, shuffle=False)
|
| 156 |
+
|
| 157 |
+
model = build_model()
|
| 158 |
+
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
|
| 159 |
+
|
| 160 |
+
steps_per_epoch = max(1, int(np.ceil(len(train_labels) / BATCH_SIZE)))
|
| 161 |
+
validation_steps = max(1, int(np.ceil(len(val_labels) / BATCH_SIZE)))
|
| 162 |
+
|
| 163 |
+
model.fit(
|
| 164 |
+
train_ds,
|
| 165 |
+
validation_data=val_ds,
|
| 166 |
+
epochs=EPOCHS,
|
| 167 |
+
steps_per_epoch=steps_per_epoch,
|
| 168 |
+
validation_steps=validation_steps,
|
| 169 |
+
verbose=1,
|
| 170 |
+
)
|
| 171 |
+
model.save('M3_best.keras')
|
| 172 |
+
print("Model saved as M3_best.keras")
|