Download app.py from deep-learning/image-forgery-detector-v2: direct link, hf CLI and curl.
- Browser
- Download file 32.7 kB
-
https://huggingface.co/spaces/deep-learning/image-forgery-detector-v2/resolve/main/app.py
- Command line
-
hf download hf://spaces/deep-learning/image-forgery-detector-v2/app.py
-
curl -L -o app.py https://huggingface.co/spaces/deep-learning/image-forgery-detector-v2/resolve/main/app.py
32.7 kB
| import streamlit as st | |
| import numpy as np | |
| import tensorflow as tf | |
| import cv2 | |
| import io | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| import matplotlib.patches as mpatches | |
| from matplotlib.patches import FancyBboxPatch, FancyArrowPatch | |
| from PIL import Image, ImageChops, ImageEnhance | |
| from tensorflow.keras import models, layers | |
| # ββ Configuration ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| IMG_SIZE = (224, 224) | |
| ELA_QUALITY = 90 | |
| ELA_SCALE = 15 | |
| BUILD_VERSION = "v3-notebook-ela-2026-06-09" | |
| # ββ Page config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.set_page_config( | |
| page_title="Image Forgery Detector β NED University", | |
| layout="wide", | |
| initial_sidebar_state="collapsed", | |
| ) | |
| # ββ Global CSS β complete dark professional theme ββββββββββββββββββββββββββββ | |
| st.markdown(""" | |
| <style> | |
| /* ββ Base surfaces ββ */ | |
| [data-testid="stAppViewContainer"], .main, .stApp { | |
| background-color: #0e1b2e !important; | |
| } | |
| /* Streamlit's own fixed top header β make it match so nothing clashes */ | |
| [data-testid="stHeader"] { | |
| background-color: #0e1b2e !important; | |
| } | |
| /* Keep clear of Streamlit's fixed top header so our banner isn't clipped */ | |
| .block-container { | |
| padding-top: 2.2rem !important; | |
| padding-bottom: 1rem; | |
| max-width: 1180px; | |
| } | |
| /* ββ Header banner ββ */ | |
| .ned-header { | |
| background: linear-gradient(90deg, #122842 0%, #1a3a63 55%, #155674 100%); | |
| color: #ffffff; | |
| padding: 20px 32px 18px 32px; | |
| margin: 0 0 22px 0; | |
| border-radius: 10px; | |
| border-left: 5px solid #4a9fd4; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.35); | |
| } | |
| .ned-header .university { | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| letter-spacing: 0.4px; | |
| color: #ffffff; | |
| } | |
| .ned-header .meta { | |
| font-size: 0.86rem; | |
| color: #8fc1e6; | |
| margin-top: 6px; | |
| display: flex; | |
| gap: 26px; | |
| flex-wrap: wrap; | |
| } | |
| .ned-header .meta span::before { | |
| content: "βΈ "; | |
| color: #4a9fd4; | |
| } | |
| /* ββ Footer banner ββ */ | |
| .ned-footer { | |
| background: linear-gradient(90deg, #122842 0%, #1a3a63 55%, #155674 100%); | |
| color: #8fc1e6; | |
| padding: 16px 32px; | |
| margin: 30px 0 8px 0; | |
| border-radius: 10px; | |
| border-left: 5px solid #4a9fd4; | |
| box-shadow: 0 2px 10px rgba(0,0,0,0.35); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| font-size: 0.82rem; | |
| } | |
| .ned-footer strong { color: #d7e8f6; } | |
| .ned-footer .ned-footer-right { | |
| color: #6a93b8; | |
| font-size: 0.74rem; | |
| text-align: right; | |
| } | |
| /* ββ Metric card ββ */ | |
| .metric-card { | |
| background: #17283f; | |
| border: 1px solid #2a435f; | |
| border-top: 3px solid #4a9fd4; | |
| border-radius: 8px; | |
| padding: 18px 16px; | |
| text-align: center; | |
| box-shadow: 0 2px 8px rgba(0,0,0,0.3); | |
| } | |
| .metric-card .metric-value { | |
| font-size: 1.95rem; | |
| font-weight: 700; | |
| color: #6fb8e8; | |
| } | |
| .metric-card .metric-label { | |
| font-size: 0.76rem; | |
| color: #9fb6cf; | |
| margin-top: 5px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| /* ββ Section headers ββ */ | |
| .section-header { | |
| font-size: 1.05rem; | |
| font-weight: 700; | |
| color: #cfe4f6; | |
| border-bottom: 2px solid #4a9fd4; | |
| padding-bottom: 6px; | |
| margin: 28px 0 14px 0; | |
| text-transform: uppercase; | |
| letter-spacing: 0.6px; | |
| } | |
| /* ββ Info pill ββ */ | |
| .info-pill { | |
| display: inline-block; | |
| background: #1c3a59; | |
| color: #8fc1e6; | |
| border-radius: 20px; | |
| padding: 3px 12px; | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| margin: 3px 4px 3px 0; | |
| border: 1px solid #2f5478; | |
| } | |
| /* ββ Tab strip ββ */ | |
| [data-testid="stTabs"] [data-baseweb="tab-list"] { | |
| background-color: #142336; | |
| border-radius: 8px 8px 0 0; | |
| padding: 5px 8px 0 8px; | |
| gap: 4px; | |
| border-bottom: 1px solid #2a435f; | |
| } | |
| [data-testid="stTabs"] [data-baseweb="tab"] { | |
| color: #8da7c2; | |
| font-weight: 600; | |
| border-radius: 6px 6px 0 0; | |
| padding: 8px 18px; | |
| } | |
| [data-testid="stTabs"] [data-baseweb="tab"] p { | |
| font-size: 1.05rem; | |
| font-weight: 600; | |
| } | |
| [data-testid="stTabs"] [aria-selected="true"] { | |
| color: #6fb8e8 !important; | |
| border-bottom: 3px solid #4a9fd4 !important; | |
| background: #17283f !important; | |
| } | |
| /* ββ Tables ββ */ | |
| .stMarkdown table { | |
| border-collapse: collapse; | |
| width: 100%; | |
| } | |
| .stMarkdown table th { | |
| background-color: #1a3050 !important; | |
| color: #cfe4f6 !important; | |
| border: 1px solid #2a435f !important; | |
| } | |
| .stMarkdown table td { | |
| background-color: #142336 !important; | |
| color: #d3e0ee !important; | |
| border: 1px solid #2a435f !important; | |
| } | |
| /* ββ Code blocks ββ */ | |
| .stCodeBlock, pre { | |
| background-color: #0a1626 !important; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ββ Header ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown(""" | |
| <div class="ned-header"> | |
| <div class="university">NED University of Engineering and Technology</div> | |
| <div class="meta"> | |
| <span>Post Graduate Diploma in Generative AI</span> | |
| <span>Course: Deep Learning</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ββ Forensic Utilities βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def compute_ela_jpeg_bytes(original, quality=ELA_QUALITY, scale=ELA_SCALE): | |
| original = original.convert('RGB') | |
| buf = io.BytesIO() | |
| original.save(buf, 'JPEG', quality=quality) | |
| buf.seek(0) | |
| recompressed = Image.open(buf).convert('RGB') | |
| ela_image = ImageChops.difference(original, recompressed) | |
| ela_image = ImageEnhance.Brightness(ela_image).enhance(scale) | |
| out = io.BytesIO() | |
| ela_image.save(out, 'JPEG') | |
| return out.getvalue() | |
| def ela_tensor(jpeg_bytes): | |
| img = tf.image.decode_jpeg(jpeg_bytes, channels=3) | |
| img = tf.image.resize(img, IMG_SIZE) | |
| return (tf.cast(img, tf.float32) / 255.0).numpy() | |
| def get_gradcam(model, input_data): | |
| last_conv_layer_name = None | |
| for layer in reversed(model.layers): | |
| if 'conv2d' in layer.name: | |
| last_conv_layer_name = layer.name | |
| break | |
| if not last_conv_layer_name: | |
| for layer in reversed(model.layers): | |
| if 'conv' in layer.name: | |
| last_conv_layer_name = layer.name | |
| break | |
| grad_model = models.Model( | |
| inputs=model.inputs, | |
| outputs=[model.get_layer(last_conv_layer_name).output, model.output] | |
| ) | |
| with tf.GradientTape() as tape: | |
| last_conv_out, preds = grad_model(input_data) | |
| class_channel = preds[:, 0] | |
| grads = tape.gradient(class_channel, last_conv_out) | |
| pooled_grads = tf.reduce_mean(grads, axis=(0, 1, 2)) | |
| heatmap = last_conv_out[0] @ pooled_grads[..., tf.newaxis] | |
| max_val = tf.math.reduce_max(heatmap) | |
| if max_val == 0: | |
| max_val = 1e-10 | |
| heatmap = tf.squeeze(tf.maximum(heatmap, 0) / max_val).numpy() | |
| return heatmap | |
| def build_model(model_type='M3'): | |
| base = tf.keras.applications.ResNet50( | |
| include_top=False, weights='imagenet', input_shape=(*IMG_SIZE, 3) | |
| ) | |
| base.trainable = False | |
| rgb_input = layers.Input(shape=(*IMG_SIZE, 3)) | |
| x = tf.keras.applications.resnet50.preprocess_input(rgb_input) | |
| x = base(x, training=False) | |
| rgb_features = layers.GlobalAveragePooling2D()(x) | |
| ela_input = layers.Input(shape=(*IMG_SIZE, 3)) | |
| x = layers.Rescaling(1. / 255)(ela_input) | |
| for filters in [32, 64, 128]: | |
| x = layers.Conv2D(filters, (3, 3), activation='relu', padding='same')(x) | |
| x = layers.BatchNormalization()(x) | |
| x = layers.MaxPooling2D((2, 2))(x) | |
| ela_features = layers.GlobalAveragePooling2D()(x) | |
| fused = layers.Concatenate()([rgb_features, ela_features]) | |
| out = layers.Dense(1, activation='sigmoid')( | |
| layers.Dropout(0.5)(layers.Dense(256, activation='relu')(fused)) | |
| ) | |
| return tf.keras.Model(inputs=[rgb_input, ela_input], outputs=out) | |
| def load_trained_model(): | |
| import os | |
| from huggingface_hub import hf_hub_download | |
| local_path = 'M3_best_v2.h5' | |
| if os.path.exists(local_path): | |
| model_path = local_path | |
| else: | |
| st.info("Downloading model from Hugging Face Hub...") | |
| model_path = hf_hub_download( | |
| repo_id="usamaalam/image-forgery-detection-model", | |
| filename="M3_best_v2.h5", | |
| cache_dir=".cache" | |
| ) | |
| try: | |
| model = tf.keras.models.load_model(model_path, compile=False) | |
| st.success("Model loaded successfully!") | |
| return model | |
| except Exception as e: | |
| st.warning(f"Full-model load failed ({e}); rebuilding architecture and loading weights...") | |
| try: | |
| model = build_model('M3') | |
| model.load_weights(model_path, by_name=True, skip_mismatch=True) | |
| st.success("Model loaded (weights-only fallback).") | |
| return model | |
| except Exception as e: | |
| st.error(f"Failed to load model: {e}") | |
| return None | |
| # ββ Architecture diagram (matplotlib) ββββββββββββββββββββββββββββββββββββββββ | |
| def make_architecture_figure(): | |
| fig, ax = plt.subplots(figsize=(11, 5.5)) | |
| ax.set_xlim(0, 11) | |
| ax.set_ylim(0, 5.5) | |
| ax.axis('off') | |
| fig.patch.set_facecolor('#0e1b2e') | |
| def box(x, y, w, h, label, sub="", color="#2c4a7c", text_color="white", fontsize=9): | |
| rect = FancyBboxPatch((x - w/2, y - h/2), w, h, | |
| boxstyle="round,pad=0.08", linewidth=1.2, | |
| edgecolor="#4a9fd4", facecolor=color) | |
| ax.add_patch(rect) | |
| ax.text(x, y + (0.12 if sub else 0), label, ha='center', va='center', | |
| color=text_color, fontsize=fontsize, fontweight='bold') | |
| if sub: | |
| ax.text(x, y - 0.28, sub, ha='center', va='center', | |
| color='#c8dff0', fontsize=7.2) | |
| def arrow(x1, y1, x2, y2): | |
| ax.annotate("", xy=(x2, y2), xytext=(x1, y1), | |
| arrowprops=dict(arrowstyle="-|>", color="#4a9fd4", | |
| lw=1.5, mutation_scale=14)) | |
| # Input nodes | |
| box(0.8, 4.0, 1.3, 0.65, "RGB Input", "224Γ224Γ3", color="#4a9fd4") | |
| box(0.8, 1.5, 1.3, 0.65, "ELA Input", "224Γ224Γ3", color="#4a9fd4") | |
| # RGB branch | |
| box(2.8, 4.0, 1.5, 0.65, "ResNet50", "frozen, ImageNet", color="#2e7bb0") | |
| box(4.5, 4.0, 1.5, 0.65, "GlobalAvgPool", "β 2048-d", color="#2e7bb0") | |
| # ELA branch | |
| box(2.8, 2.8, 1.5, 0.65, "Conv2D 32", "3Γ3, ReLU+BN+Pool", color="#2a8f56") | |
| box(2.8, 1.9, 1.5, 0.65, "Conv2D 64", "3Γ3, ReLU+BN+Pool", color="#2a8f56") | |
| box(2.8, 1.0, 1.5, 0.65, "Conv2D 128", "3Γ3, ReLU+BN+Pool", color="#2a8f56") | |
| box(4.5, 1.5, 1.5, 0.65, "GlobalAvgPool", "β 128-d", color="#2a8f56") | |
| # Concat | |
| box(6.4, 2.75, 1.3, 0.65, "Concatenate", "2176-d", color="#9b59b6") | |
| # Dense head | |
| box(8.0, 2.75, 1.3, 0.65, "Dense 256", "ReLU + Drop 0.5", color="#8e44ad") | |
| box(9.8, 2.75, 1.1, 0.65, "Dense 1", "Sigmoid", color="#c0392b") | |
| # Arrows β RGB branch | |
| arrow(1.45, 4.0, 2.05, 4.0) | |
| arrow(3.55, 4.0, 3.75, 4.0) | |
| arrow(5.25, 4.0, 5.65, 4.0) | |
| ax.plot([5.65, 5.85, 5.85], [4.0, 4.0, 2.75], color="#4a9fd4", lw=1.5) | |
| arrow(5.85, 2.75, 5.75, 2.75) | |
| # Arrows β ELA branch | |
| arrow(1.45, 1.5, 2.05, 1.5) | |
| ax.plot([1.45, 1.7, 1.7], [1.5, 1.5, 2.8], color="#4a9fd4", lw=1.5) | |
| arrow(1.7, 2.8, 2.05, 2.8) | |
| ax.plot([1.7, 1.7, 1.7], [2.8, 1.9, 1.0], color="#4a9fd4", lw=1.5) | |
| arrow(1.7, 1.9, 2.05, 1.9) | |
| arrow(1.7, 1.0, 2.05, 1.0) | |
| arrow(3.55, 2.8, 3.75, 2.8) | |
| arrow(3.55, 1.9, 3.75, 1.9) | |
| arrow(3.55, 1.0, 3.75, 1.0) | |
| ax.plot([5.25, 5.65, 5.65], [1.5, 1.5, 2.75], color="#27ae60", lw=1.5) | |
| arrow(5.65, 2.75, 5.75, 2.75) | |
| arrow(3.55, 1.5, 3.75, 1.5) | |
| ax.plot([4.5, 4.5], [2.8, 2.18], color="#27ae60", lw=1.5) | |
| ax.plot([4.5, 4.5], [1.83, 1.18], color="#27ae60", lw=1.5) | |
| ax.plot([4.5, 4.5], [0.83, 1.18], color="#27ae60", lw=1.5) | |
| # Arrows β fusion β output | |
| arrow(7.05, 2.75, 7.35, 2.75) | |
| arrow(8.65, 2.75, 9.25, 2.75) | |
| # Output label | |
| ax.text(10.6, 2.75, "0 / 1\nAuth /\nForged", | |
| ha='center', va='center', fontsize=8, color="#e8a0a0", fontweight='bold') | |
| # Legend | |
| legend_items = [ | |
| mpatches.Patch(color="#4a9fd4", label="Input"), | |
| mpatches.Patch(color="#2e7bb0", label="RGB Branch (ResNet50)"), | |
| mpatches.Patch(color="#2a8f56", label="ELA Branch (Custom CNN)"), | |
| mpatches.Patch(color="#9b59b6", label="Fusion"), | |
| mpatches.Patch(color="#c0392b", label="Output Head"), | |
| ] | |
| leg = ax.legend(handles=legend_items, loc='lower center', ncol=5, | |
| fontsize=7.5, framealpha=0.0, bbox_to_anchor=(0.48, -0.02), | |
| labelcolor='#c8dff0') | |
| leg.get_frame().set_edgecolor('#2a435f') | |
| fig.tight_layout(pad=0.4) | |
| buf = io.BytesIO() | |
| fig.savefig(buf, format='png', dpi=130, bbox_inches='tight', | |
| facecolor=fig.get_facecolor()) | |
| plt.close(fig) | |
| buf.seek(0) | |
| return buf | |
| # ββ Tabs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| tab1, tab2 = st.tabs(["π Forgery Detector", "π Model & Training"]) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TAB 1 β Forgery Detector | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab1: | |
| st.title("π‘οΈ Image Forgery Detector") | |
| st.markdown(""" | |
| Detect tampering in images using a Dual-Branch CNN (RGB + ELA). | |
| Upload an image to see if it's **Authentic** or **Forged**. | |
| """) | |
| st.info( | |
| "βΉοΈ This model was trained on the **CASIA v2** forensics dataset and works best " | |
| "on CASIA-style images. It is **ELA-driven**, so high-quality phone photos are " | |
| "out-of-distribution and may be flagged unreliably. Test accuracy on CASIA: ~92%." | |
| ) | |
| uploaded_file = st.file_uploader("Choose an image...", type=["jpg", "jpeg", "png", "tif"]) | |
| if uploaded_file is not None: | |
| image = Image.open(uploaded_file).convert('RGB') | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.image(image, caption="Original Image", use_container_width=True) | |
| with st.spinner("Analyzing..."): | |
| m3 = load_trained_model() | |
| rgb_in = np.array(image.resize(IMG_SIZE, Image.LANCZOS), np.float32)[np.newaxis] / 255.0 | |
| ela_bytes = compute_ela_jpeg_bytes(image) | |
| ela_in = ela_tensor(ela_bytes)[np.newaxis] | |
| ela_img = Image.open(io.BytesIO(ela_bytes)).convert('RGB') | |
| input_data = [rgb_in, ela_in] | |
| pred = m3.predict(input_data, verbose=0)[0][0] | |
| label = "FORGED" if pred > 0.5 else "AUTHENTIC" | |
| confidence = pred if pred > 0.5 else 1 - pred | |
| if 0.45 <= pred <= 0.55: | |
| label = "UNCERTAIN" | |
| with col2: | |
| st.subheader("Prediction Result") | |
| color = "red" if label == "FORGED" else "green" if label == "AUTHENTIC" else "orange" | |
| st.markdown( | |
| f"### Result: <span style='color:{color}'>{label}</span>", | |
| unsafe_allow_html=True | |
| ) | |
| st.write(f"**Confidence:** {confidence:.2%}") | |
| st.progress(float(confidence)) | |
| with st.expander("π¬ Debug info (preprocessing & model)"): | |
| try: | |
| in_order = [getattr(i, "name", "?") for i in m3.inputs] | |
| except Exception: | |
| in_order = "n/a" | |
| st.code( | |
| f"build : {BUILD_VERSION}\n" | |
| f"raw pred : {float(pred):.6f}\n" | |
| f"model input: {in_order}\n" | |
| f"rgb shape={rgb_in.shape} mean={rgb_in.mean():.4f} max={rgb_in.max():.4f}\n" | |
| f"ela shape={ela_in.shape} mean={ela_in.mean():.4f} max={ela_in.max():.4f}\n" | |
| f"orig size : {image.size}" | |
| ) | |
| st.divider() | |
| col3, col4 = st.columns(2) | |
| with col3: | |
| st.subheader("ELA Artifacts") | |
| st.image(ela_img, caption="Error Level Analysis (JPEG inconsistencies)", | |
| use_container_width=True) | |
| st.info("ELA highlights regions with different compression levels, " | |
| "often indicating tampered areas.") | |
| with col4: | |
| st.subheader("Grad-CAM Explainability") | |
| try: | |
| heatmap = get_gradcam(m3, input_data) | |
| heatmap_color = cv2.applyColorMap(np.uint8(255 * heatmap), cv2.COLORMAP_JET) | |
| heatmap_color = cv2.cvtColor(heatmap_color, cv2.COLOR_BGR2RGB) | |
| heatmap_resized = cv2.resize(heatmap_color, (image.size[0], image.size[1])) | |
| img_np = np.array(image) | |
| overlay = np.uint8(heatmap_resized * 0.4 + img_np * 0.6) | |
| st.image(overlay, caption="Model Focus Regions", use_container_width=True) | |
| st.info("The heatmap shows which parts of the image the model focused on " | |
| "to make its decision.") | |
| except Exception as e: | |
| st.error(f"Could not generate Grad-CAM: {e}") | |
| else: | |
| st.info("Please upload an image to start detection.") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # TAB 2 β Model & Training | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tab2: | |
| st.title("π Model Architecture & Training Details") | |
| st.markdown( | |
| "A complete reference for the **M3 Dual-Branch CNN** β architecture, " | |
| "hyperparameters, dataset, and evaluation results." | |
| ) | |
| # ββ Section 1: Architecture diagram ββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">1 Β· Model Architecture</div>', | |
| unsafe_allow_html=True) | |
| arch_buf = make_architecture_figure() | |
| st.image(arch_buf, caption="M3 Dual-Branch CNN β forward pass overview", | |
| use_container_width=True) | |
| st.markdown(""" | |
| The model fuses **two complementary views** of every image: | |
| - **RGB Branch** β a frozen ResNet50 backbone (pretrained on ImageNet) extracts high-level | |
| semantic and texture features from the raw pixel data. | |
| - **ELA Branch** β a lightweight custom CNN (3 convolutional blocks, 32 β 64 β 128 filters) | |
| processes the Error Level Analysis image, which amplifies JPEG compression inconsistencies | |
| left by image manipulation. | |
| Both 2048-d and 128-d feature vectors are concatenated, then classified through a | |
| Dense(256) β Dropout(0.5) β Dense(1, Sigmoid) head. | |
| """) | |
| with st.expander("Layer-by-layer parameter table"): | |
| st.markdown(""" | |
| | Branch | Layer | Output Shape | Parameters | | |
| |---|---|---|---| | |
| | RGB | ResNet50 (frozen) | 7Γ7Γ2048 | ~23,587,712 (frozen) | | |
| | RGB | GlobalAveragePooling2D | 2048 | 0 | | |
| | ELA | Conv2D(32) + BN + MaxPool | 112Γ112Γ32 | ~896 + 128 | | |
| | ELA | Conv2D(64) + BN + MaxPool | 56Γ56Γ64 | ~18,496 + 256 | | |
| | ELA | Conv2D(128) + BN + MaxPool | 28Γ28Γ128 | ~73,856 + 512 | | |
| | ELA | GlobalAveragePooling2D | 128 | 0 | | |
| | Head | Dense(256, ReLU) | 256 | 558,336 | | |
| | Head | Dropout(0.5) | 256 | 0 | | |
| | Head | Dense(1, Sigmoid) | 1 | 257 | | |
| | **Total** | | | **~24.24M total / ~652K trainable** | | |
| """) | |
| # ββ Section 2: Training Configuration ββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">2 Β· Training Configuration</div>', | |
| unsafe_allow_html=True) | |
| cfg_col1, cfg_col2 = st.columns(2) | |
| with cfg_col1: | |
| st.markdown("**Dataset**") | |
| st.markdown(""" | |
| | Property | Value | | |
| |---|---| | |
| | Dataset | CASIA v2 | | |
| | Authentic images | 7,492 | | |
| | Tampered images | 5,124 | | |
| | Total | 12,616 | | |
| | Train split | 70% | | |
| | Validation split | 15% | | |
| | Test split | 15% | | |
| | Split strategy | Stratified, SEED = 42 | | |
| | Leakage prevention | Donor/target IDs kept in same split | | |
| """) | |
| with cfg_col2: | |
| st.markdown("**Hyperparameters & Preprocessing**") | |
| st.markdown(""" | |
| | Hyperparameter | Value | | |
| |---|---| | |
| | Optimizer | Adam (default lr = 0.001) | | |
| | Loss | Binary Cross-Entropy | | |
| | Batch size | 32 | | |
| | Input size | 224 Γ 224 Γ 3 | | |
| | RGB normalization | Γ· 255 β \[0, 1\] | | |
| | ELA JPEG quality | 90 | | |
| | ELA amplification | 15Γ (Brightness.enhance) | | |
| | ELA decoder | tf.image.decode\_jpeg + bilinear resize | | |
| | Framework | TensorFlow 2.20 / Keras | | |
| | Training platform | Google Colab (GPU) | | |
| """) | |
| st.markdown("**ELA Preprocessing Pipeline**") | |
| st.markdown(""" | |
| ``` | |
| Input image | |
| βββΊ Save as JPEG (quality=90) β re-compress | |
| βββΊ Diff(original, recompressed) β pixel-wise difference | |
| βββΊ Brightness.enhance(scale=15) β amplify inconsistencies | |
| βββΊ Save to JPEG bytes (quality=75) β match training cache format | |
| βββΊ tf.image.decode_jpeg(channels=3) β decode | |
| βββΊ tf.image.resize([224,224]) β bilinear interpolation | |
| βββΊ cast(float32) Γ· 255.0 β normalize to [0,1] | |
| ``` | |
| Tampered pixels were re-saved at a different quality level; the diff reveals those boundaries. | |
| """) | |
| # ββ Section 3: Performance Metrics βββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">3 Β· Evaluation Results</div>', | |
| unsafe_allow_html=True) | |
| m1, m2, m3_col, m4 = st.columns(4) | |
| with m1: | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <div class="metric-value">0.9774</div> | |
| <div class="metric-label">AUC-ROC (test split)</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with m2: | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <div class="metric-value">~92%</div> | |
| <div class="metric-label">Test Accuracy</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with m3_col: | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <div class="metric-value">0.50</div> | |
| <div class="metric-label">Decision Threshold</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| with m4: | |
| st.markdown(""" | |
| <div class="metric-card"> | |
| <div class="metric-value">~8%</div> | |
| <div class="metric-label">Error Rate (both classes)</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| perf_col1, perf_col2 = st.columns(2) | |
| with perf_col1: | |
| st.markdown("**Per-class score distribution (n = 300 test images)**") | |
| st.markdown(""" | |
| | Class | Min | Median | Max | Correct classification | | |
| |---|---|---|---|---| | |
| | Authentic | 0.000 | 0.008 | 0.980 | ~92% | | |
| | Forged | 0.020 | 0.968 | 1.000 | ~92% | | |
| """) | |
| st.caption( | |
| "Scores are tightly clustered near 0 (authentic) and 1 (forged), " | |
| "confirming strong separation between classes." | |
| ) | |
| with perf_col2: | |
| # Score distribution bar chart | |
| fig2, ax2 = plt.subplots(figsize=(5, 2.8)) | |
| fig2.patch.set_facecolor('#0e1b2e') | |
| ax2.set_facecolor('#142336') | |
| bins = np.linspace(0, 1, 21) | |
| au_scores = np.concatenate([ | |
| np.random.default_rng(1).beta(0.4, 30, 190), | |
| np.random.default_rng(1).uniform(0.5, 1.0, 10), | |
| ]) | |
| tp_scores = np.concatenate([ | |
| np.random.default_rng(2).beta(30, 0.4, 100), | |
| np.random.default_rng(2).uniform(0.0, 0.5, 10), | |
| ]) | |
| ax2.hist(au_scores, bins=bins, alpha=0.8, color='#2ecc71', label='Authentic') | |
| ax2.hist(tp_scores, bins=bins, alpha=0.8, color='#e74c3c', label='Forged') | |
| ax2.axvline(0.5, color='#e6eef7', lw=1.5, linestyle='--', label='Threshold 0.5') | |
| ax2.set_xlabel("Model score", fontsize=8, color='#c8dff0') | |
| ax2.set_ylabel("Count", fontsize=8, color='#c8dff0') | |
| ax2.set_title("Illustrative score distribution", fontsize=8.5, | |
| fontweight='bold', color='#cfe4f6') | |
| leg2 = ax2.legend(fontsize=7.5, framealpha=0.0, labelcolor='#c8dff0') | |
| ax2.tick_params(labelsize=7, colors='#9fb6cf') | |
| for spine in ax2.spines.values(): | |
| spine.set_color('#2a435f') | |
| fig2.tight_layout(pad=0.5) | |
| buf2 = io.BytesIO() | |
| fig2.savefig(buf2, format='png', dpi=120, bbox_inches='tight', | |
| facecolor=fig2.get_facecolor()) | |
| plt.close(fig2) | |
| buf2.seek(0) | |
| st.image(buf2, use_container_width=True) | |
| st.caption("Green = Authentic, Red = Forged. Dashed line = decision boundary.") | |
| # ββ Section 4: Branch comparison βββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">4 Β· Branch Ablation Study</div>', | |
| unsafe_allow_html=True) | |
| abl_col1, abl_col2 = st.columns([1, 1]) | |
| with abl_col1: | |
| st.markdown(""" | |
| Ablation experiments reveal the dominant role of the ELA branch: | |
| | Model variant | AUC-ROC | | |
| |---|---| | |
| | M1 β RGB only (ResNet50) | 0.5822 | | |
| | M2 β ELA only (custom CNN) | 0.9807 | | |
| | **M3 β Dual-branch (RGB + ELA)** | **0.9774** | | |
| The RGB branch alone is near-random (AUC β 0.58), while the ELA branch alone | |
| achieves 0.98. This confirms the model is fundamentally **ELA-driven** β the | |
| ResNet50 backbone provides complementary texture context but does not dominate. | |
| """) | |
| with abl_col2: | |
| fig3, ax3 = plt.subplots(figsize=(4.5, 2.6)) | |
| fig3.patch.set_facecolor('#0e1b2e') | |
| ax3.set_facecolor('#142336') | |
| variants = ['M1\nRGB only', 'M2\nELA only', 'M3\nDual-branch'] | |
| aucs = [0.5822, 0.9807, 0.9774] | |
| colors = ['#7f8c9a', '#2ecc71', '#4a9fd4'] | |
| bars = ax3.bar(variants, aucs, color=colors, width=0.5, | |
| edgecolor='#0e1b2e', linewidth=0.8) | |
| ax3.set_ylim(0.4, 1.05) | |
| ax3.axhline(1.0, color='#3a5575', lw=0.8, linestyle=':') | |
| for bar, val in zip(bars, aucs): | |
| ax3.text(bar.get_x() + bar.get_width()/2, val + 0.01, | |
| f"{val:.4f}", ha='center', va='bottom', fontsize=8, | |
| fontweight='bold', color='#cfe4f6') | |
| ax3.set_ylabel("AUC-ROC", fontsize=8, color='#c8dff0') | |
| ax3.set_title("Branch Ablation β AUC-ROC", fontsize=8.5, | |
| fontweight='bold', color='#cfe4f6') | |
| ax3.tick_params(labelsize=7.5, colors='#9fb6cf') | |
| for spine in ax3.spines.values(): | |
| spine.set_color('#2a435f') | |
| fig3.tight_layout(pad=0.5) | |
| buf3 = io.BytesIO() | |
| fig3.savefig(buf3, format='png', dpi=120, bbox_inches='tight', | |
| facecolor=fig3.get_facecolor()) | |
| plt.close(fig3) | |
| buf3.seek(0) | |
| st.image(buf3, use_container_width=True) | |
| # ββ Section 5: ELA Explained ββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">5 Β· Error Level Analysis β How It Works</div>', | |
| unsafe_allow_html=True) | |
| ela_col1, ela_col2 = st.columns(2) | |
| with ela_col1: | |
| st.markdown(""" | |
| **The JPEG compression insight** | |
| Every time a JPEG image is saved, lossy compression introduces small quantization | |
| errors. If a region is copy-pasted from another image (or saved at a different quality), | |
| it will have a *different error level* than the surrounding original pixels. | |
| **ELA Pipeline:** | |
| 1. Re-save the input image as JPEG at `quality=90` | |
| 2. Compute pixel-wise absolute difference: `|original β recompressed|` | |
| 3. Amplify by `15Γ` (Brightness enhancement) so subtle differences become visible | |
| 4. Encode to JPEG bytes (quality 75) β matches the training cache format | |
| 5. Decode via `tf.image.decode_jpeg` and resize bilinearly to 224Γ224 | |
| **What the ELA branch learns:** | |
| - Tampered regions tend to show *brighter* patches in the ELA map | |
| - Uniform, flat color regions show near-zero ELA (no compression residual) | |
| - Copy-move forgeries leave telltale boundary artifacts at the splice edges | |
| """) | |
| with ela_col2: | |
| st.markdown(""" | |
| **Why preprocessing must match exactly** | |
| The M3 model was trained on ELA images cached to disk as JPEG files and decoded | |
| with `tf.image.decode_jpeg` (bilinear resize). Using a different pipeline β even | |
| a subtle change like PIL bicubic resize or skipping the JPEG encode step β shifts | |
| the feature distribution and causes the model to mispredict. | |
| **Known limitations:** | |
| <span class="info-pill">Out-of-distribution</span> | |
| High-quality phone photos are processed differently by phone ISPs | |
| (multi-frame stacking, HDR merge). The ELA residuals are large everywhere, | |
| causing the model to flag them as forged. | |
| <span class="info-pill">CASIA-specific</span> | |
| The model was trained exclusively on CASIA v2 (splicing + copy-move forgeries). | |
| It may not generalize to other forgery types (e.g., GAN-generated images, | |
| deepfakes, inpainting artifacts). | |
| <span class="info-pill">No fine-tuning</span> | |
| The ResNet50 backbone is completely frozen. Fine-tuning the top layers on | |
| a larger, more diverse dataset would improve real-world generalization. | |
| """, unsafe_allow_html=True) | |
| # ββ Section 6: Technology Stack βββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown('<div class="section-header">6 Β· Technology Stack</div>', | |
| unsafe_allow_html=True) | |
| tech_col1, tech_col2, tech_col3 = st.columns(3) | |
| with tech_col1: | |
| st.markdown("**Core ML**") | |
| st.markdown(""" | |
| | Library | Version | | |
| |---|---| | |
| | TensorFlow / Keras | 2.20.0 | | |
| | NumPy | 1.26.4 | | |
| | scikit-learn | 1.5.2 | | |
| """) | |
| with tech_col2: | |
| st.markdown("**Image Processing**") | |
| st.markdown(""" | |
| | Library | Version | | |
| |---|---| | |
| | Pillow | 10.4.0 | | |
| | OpenCV (headless) | 4.10.0.84 | | |
| | Matplotlib | 3.9.2 | | |
| """) | |
| with tech_col3: | |
| st.markdown("**Deployment**") | |
| st.markdown(""" | |
| | Component | Details | | |
| |---|---| | |
| | App framework | Streamlit 1.40.2 | | |
| | Model hosting | Hugging Face Hub | | |
| | Inference platform | HF Spaces | | |
| | Model file | M3\_best\_v2.h5 (~98 MB) | | |
| """) | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.info( | |
| "**Dataset:** CASIA v2 (Chinese Academy of Sciences Image Splicing Dataset v2) β " | |
| "a standard benchmark for image forgery detection containing authentic and " | |
| "tampered image pairs across multiple scene categories." | |
| ) | |
| # ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| st.markdown(""" | |
| <div class="ned-footer"> | |
| <div> | |
| <strong>Contributors:</strong> Salman Zaman Β· Muhammad Usama Alam Β· Muhammad Zafar Khan | |
| </div> | |
| <div> | |
| <strong>Project Coordinator:</strong> Sajid Majeed | |
| </div> | |
| <div class="ned-footer-right"> | |
| NED University of Engineering and Technology Β· | |
| PG Diploma in Generative AI Β· Deep Learning | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |