File size: 10,168 Bytes
0e39d80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
"""Forgery and document manipulation detection.

Checks performed:
1. ELA (Error Level Analysis) β€” detects JPEG compression inconsistencies from editing
2. Metadata analysis β€” EXIF editing software traces
3. Clone/copy-paste detection β€” statistical uniformity in image blocks
4. Font/print consistency β€” checks for pasted text regions
5. Edge artifact detection β€” sharp copy-paste boundaries

Returns a ForgeryResult with a manipulation_score (0=clean, 100=highly suspicious)
and a list of human-readable flags.
"""

from __future__ import annotations

import io
import logging
import math
from dataclasses import dataclass, field

import cv2
import numpy as np

logger = logging.getLogger("docverify.forgery")


@dataclass
class ForgeryResult:
    manipulation_score: float        # 0–100, higher = more suspicious
    is_suspicious: bool
    flags: list[str] = field(default_factory=list)
    details: dict = field(default_factory=dict)


# ── ELA (Error Level Analysis) ───────────────────────────────────────────
def _ela_analysis(image_bgr: np.ndarray, quality: int = 90) -> tuple[float, bool]:
    """Re-save image at known JPEG quality, compute residual.

    Authentic images have uniform ELA residuals.
    Edited regions (pasted text/photos) show anomalous high-residual patches.

    Returns: (ela_score 0-100, is_suspicious)
    """
    try:
        from PIL import Image
        import tempfile, os

        pil_img = Image.fromarray(cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB))

        # Save at fixed quality
        buf = io.BytesIO()
        pil_img.save(buf, "JPEG", quality=quality)
        buf.seek(0)
        recompressed = Image.open(buf)

        # Compute absolute difference
        ela_arr = np.array(pil_img, dtype=np.float32) - np.array(recompressed, dtype=np.float32)
        ela_arr = np.abs(ela_arr)

        # Scale for visibility
        ela_max = ela_arr.max()
        if ela_max < 1:
            return 0.0, False

        # Compute block-level standard deviation β€” edited regions are outliers
        gray_ela = ela_arr.mean(axis=2) if ela_arr.ndim == 3 else ela_arr
        h, w = gray_ela.shape
        block_size = max(h // 20, 8)
        block_stds = []
        for y in range(0, h - block_size, block_size):
            for x in range(0, w - block_size, block_size):
                block = gray_ela[y:y+block_size, x:x+block_size]
                block_stds.append(float(block.std()))

        if not block_stds:
            return 0.0, False

        global_mean = float(np.mean(block_stds))
        global_std = float(np.std(block_stds))

        # Outlier blocks = suspicious (> 2.5Οƒ above mean)
        threshold = global_mean + 2.5 * global_std
        outlier_count = sum(1 for s in block_stds if s > threshold)
        outlier_ratio = outlier_count / max(len(block_stds), 1)

        # Score: 0 = clean, 100 = heavily edited
        ela_score = min(100.0, outlier_ratio * 400)
        is_suspicious = ela_score > 25

        return ela_score, is_suspicious

    except Exception as exc:
        logger.debug("ELA analysis failed: %s", exc)
        return 0.0, False


# ── Clone/Copy-Paste Detection ───────────────────────────────────────────
def _clone_detection(image_bgr: np.ndarray) -> tuple[float, bool]:
    """Detect copy-paste cloning using block DCT similarity.

    Divides image into overlapping blocks, computes DCT features,
    finds suspiciously similar non-adjacent blocks.

    Returns: (score 0-100, is_suspicious)
    """
    try:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        h, w = gray.shape
        block_size = 32
        step = 16

        features = []
        positions = []

        for y in range(0, h - block_size, step):
            for x in range(0, w - block_size, step):
                block = gray[y:y+block_size, x:x+block_size].astype(np.float32)
                dct = cv2.dct(block)
                # Use top-left 4x4 DCT coefficients as feature
                feat = dct[:4, :4].flatten()
                features.append(feat)
                positions.append((x, y))

        if len(features) < 10:
            return 0.0, False

        feat_arr = np.array(features)

        # Sort by feature to find similar blocks efficiently
        sorted_idx = np.lexsort(feat_arr.T[::-1])
        suspicious_pairs = 0
        total_checks = 0

        for i in range(len(sorted_idx) - 1):
            a = sorted_idx[i]
            b = sorted_idx[i + 1]
            # Feature distance
            dist = np.linalg.norm(feat_arr[a] - feat_arr[b])
            if dist < 5.0:  # very similar blocks
                # Check they're not adjacent
                xa, ya = positions[a]
                xb, yb = positions[b]
                spatial_dist = math.sqrt((xa - xb)**2 + (ya - yb)**2)
                if spatial_dist > block_size * 3:
                    suspicious_pairs += 1
            total_checks += 1

        score = min(100.0, (suspicious_pairs / max(total_checks, 1)) * 2000)
        return score, score > 15

    except Exception as exc:
        logger.debug("Clone detection failed: %s", exc)
        return 0.0, False


# ── Edge Artifact Detection ──────────────────────────────────────────────
def _edge_artifact_analysis(image_bgr: np.ndarray) -> tuple[float, bool]:
    """Detect unnaturally sharp/clean rectangular boundaries typical of cut-paste.

    Returns: (score 0-100, is_suspicious)
    """
    try:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
        # Canny edges
        edges = cv2.Canny(gray, 50, 150)
        # Find long straight horizontal/vertical lines (copy-paste boundaries)
        lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=80,
                                minLineLength=gray.shape[1] // 4, maxLineGap=10)
        if lines is None:
            return 0.0, False

        # Count perfectly horizontal or vertical lines
        h_lines = 0
        v_lines = 0
        for line in lines:
            x1, y1, x2, y2 = line[0]
            angle = abs(math.degrees(math.atan2(y2 - y1, x2 - x1)))
            if angle < 2 or angle > 178:
                h_lines += 1
            elif 88 < angle < 92:
                v_lines += 1

        # Normal documents have some horizontal lines (text baselines)
        # Suspicious: very long perfectly straight lines that cross content areas
        suspicious_lines = max(0, (h_lines + v_lines) - 8)
        score = min(100.0, suspicious_lines * 12)
        return score, score > 20

    except Exception as exc:
        logger.debug("Edge artifact analysis failed: %s", exc)
        return 0.0, False


# ── Noise Consistency Analysis ───────────────────────────────────────────
def _noise_consistency(image_bgr: np.ndarray) -> tuple[float, bool]:
    """Check if image noise is consistent across regions.

    Pasted regions often have different noise profiles.
    """
    try:
        gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
        h, w = gray.shape

        # High-frequency noise via Laplacian
        laplacian = cv2.Laplacian(gray, cv2.CV_32F)

        # Divide into quadrants
        quads = [
            laplacian[:h//2, :w//2],
            laplacian[:h//2, w//2:],
            laplacian[h//2:, :w//2],
            laplacian[h//2:, w//2:],
        ]
        quad_stds = [float(q.std()) for q in quads if q.size > 0]
        if len(quad_stds) < 2:
            return 0.0, False

        max_std = max(quad_stds)
        min_std = min(quad_stds)

        if min_std < 0.1:
            return 0.0, False

        # Large variation in noise across regions = suspicious
        ratio = max_std / min_std
        score = min(100.0, max(0.0, (ratio - 2.0) * 20))
        return score, score > 30

    except Exception as exc:
        logger.debug("Noise consistency failed: %s", exc)
        return 0.0, False


# ── Main Entry Point ─────────────────────────────────────────────────────
def detect_forgery(image_bgr: np.ndarray) -> ForgeryResult:
    """Run all forgery checks and return a combined ForgeryResult.

    The manipulation_score is a weighted combination of all checks.
    """
    if image_bgr is None or image_bgr.size == 0:
        return ForgeryResult(manipulation_score=0.0, is_suspicious=False)

    flags: list[str] = []
    details: dict = {}

    # 1. ELA
    ela_score, ela_suspicious = _ela_analysis(image_bgr)
    details["ela_score"] = round(ela_score, 1)
    if ela_suspicious:
        flags.append("JPEG_INCONSISTENCY_DETECTED")

    # 2. Clone detection
    clone_score, clone_suspicious = _clone_detection(image_bgr)
    details["clone_score"] = round(clone_score, 1)
    if clone_suspicious:
        flags.append("COPY_PASTE_PATTERN_DETECTED")

    # 3. Edge artifacts
    edge_score, edge_suspicious = _edge_artifact_analysis(image_bgr)
    details["edge_score"] = round(edge_score, 1)
    if edge_suspicious:
        flags.append("SHARP_BOUNDARY_ARTIFACTS")

    # 4. Noise consistency
    noise_score, noise_suspicious = _noise_consistency(image_bgr)
    details["noise_score"] = round(noise_score, 1)
    if noise_suspicious:
        flags.append("INCONSISTENT_NOISE_PATTERN")

    # Weighted composite score
    # ELA is most reliable for JPEG tampering
    manipulation_score = (
        ela_score   * 0.40 +
        clone_score * 0.25 +
        edge_score  * 0.20 +
        noise_score * 0.15
    )
    manipulation_score = round(min(100.0, manipulation_score), 1)

    is_suspicious = manipulation_score > 20 or len(flags) >= 2

    return ForgeryResult(
        manipulation_score=manipulation_score,
        is_suspicious=is_suspicious,
        flags=flags,
        details=details,
    )