| 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 |
|
|
| |
| IMG_SIZE = (224, 224) |
| ELA_QUALITY = 90 |
| ELA_SCALE = 15 |
| BUILD_VERSION = "v3-notebook-ela-2026-06-09" |
|
|
| |
| st.set_page_config( |
| page_title="Image Forgery Detector β NED University", |
| layout="wide", |
| initial_sidebar_state="collapsed", |
| ) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
|
|
| @st.cache_resource |
| 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 |
|
|
|
|
| |
| @st.cache_data |
| 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)) |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| 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") |
|
|
| |
| box(6.4, 2.75, 1.3, 0.65, "Concatenate", "2176-d", color="#9b59b6") |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| arrow(7.05, 2.75, 7.35, 2.75) |
| arrow(8.65, 2.75, 9.25, 2.75) |
|
|
| |
| ax.text(10.6, 2.75, "0 / 1\nAuth /\nForged", |
| ha='center', va='center', fontsize=8, color="#e8a0a0", fontweight='bold') |
|
|
| |
| 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 |
|
|
|
|
| |
| tab1, tab2 = st.tabs(["π Forgery Detector", "π Model & Training"]) |
|
|
| |
| |
| |
| 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.") |
|
|
|
|
| |
| |
| |
| 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." |
| ) |
|
|
| |
| 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** | |
| """) |
|
|
| |
| 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. |
| """) |
|
|
| |
| 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: |
| |
| 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.") |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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." |
| ) |
|
|
|
|
| |
| 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) |
|
|