truscan / route /clone_detection_module.py
Manar55's picture
Deploy a5180b1
82d7501 verified
Raw History Blame Contribute Delete
4.78 kB
"""
clone_detection_module.py — classic clone/copy-move forgery detection.
METHOD: detects ORB keypoints across the image, matches each keypoint's
descriptor against all OTHER keypoints in the SAME image, and flags pairs
that are (a) near-identical in appearance (very low descriptor distance)
but (b) spatially far apart (not just neighboring pixels of a repetitive
texture like grass or skin). Real copy-paste forgeries typically produce
several such matches that share a consistent displacement vector (the
pasted region moved by the same dx/dy from its source) — so this also
clusters matches by displacement and scores based on the largest
consistent cluster, not just raw match count (reduces false positives on
naturally repetitive textures, which produce scattered, inconsistent
matches rather than one strong cluster).
STATUS: added directly per request, NOT standalone-tested first (unlike
noise_residual_detector, which was validated via test_noise_residual.py
before integration). Wired in as INFORMATIONAL ONLY — logged in results
and calibration data, but not in the weighted blend — until you run
bulk_calibrate.py + recalibrate.py and confirm it's actually predictive
on real data. Same safety pattern used for sdxl_face_detector.
Pure OpenCV/numpy — no model to load, always available, no None result
except on hard image-decode errors.
"""
import numpy as np
import cv2
def _find_clone_regions(gray, max_dim=800, n_features=2000,
hamming_threshold=30, min_spatial_dist_frac=0.05,
displacement_bin=8):
h, w = gray.shape
scale = min(1.0, max_dim / max(h, w))
if scale < 1.0:
gray = cv2.resize(gray, (int(w * scale), int(h * scale)))
h2, w2 = gray.shape
min_spatial_dist = min_spatial_dist_frac * np.sqrt(h2 ** 2 + w2 ** 2)
orb = cv2.ORB_create(nfeatures=n_features)
keypoints, descriptors = orb.detectAndCompute(gray, None)
if descriptors is None or len(keypoints) < 20:
return {"n_keypoints": len(keypoints) if keypoints else 0, "largest_cluster": 0, "candidate_matches": 0}
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
# k=3: index 0 is always the point matching itself (distance 0),
# so we look at neighbors 1 and 2 for genuine near-duplicates.
matches = bf.knnMatch(descriptors, descriptors, k=3)
displacement_votes = {}
candidate_count = 0
for m_list in matches:
for m in m_list[1:]: # skip self-match at index 0
if m.distance > hamming_threshold:
continue
p1 = np.array(keypoints[m.queryIdx].pt)
p2 = np.array(keypoints[m.trainIdx].pt)
spatial_dist = np.linalg.norm(p1 - p2)
if spatial_dist < min_spatial_dist:
continue # too close — likely just a repetitive local texture, not a paste
candidate_count += 1
dx, dy = p2 - p1
# Bin the displacement vector so near-identical translations
# (the hallmark of "copy this region, paste it shifted") count
# as the same cluster even with a few pixels of noise.
bin_key = (round(dx / displacement_bin), round(dy / displacement_bin))
displacement_votes[bin_key] = displacement_votes.get(bin_key, 0) + 1
largest_cluster = max(displacement_votes.values()) if displacement_votes else 0
return {
"n_keypoints": len(keypoints),
"largest_cluster": largest_cluster,
"candidate_matches": candidate_count,
}
def classify_image(pil_image):
"""Returns a 0-100 suspicion score (100 = strong evidence of a
copy-pasted region within the same image), or None on a hard error."""
try:
rgb = np.array(pil_image.convert("RGB"))
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
stats = _find_clone_regions(gray)
if stats["n_keypoints"] < 20:
return 0.0 # not enough texture/features to say anything meaningful
# Scale: a "largest_cluster" of matches sharing one displacement
# vector is the key evidence. A handful of scattered candidate
# matches with no shared displacement (largest_cluster stays
# small) is weak/no evidence — natural images often have SOME
# repetitive texture (sky, grass, brick patterns).
cluster = stats["largest_cluster"]
if cluster >= 15:
suspicion = 100.0
elif cluster >= 8:
suspicion = 60.0 + (cluster - 8) * (40.0 / 7)
elif cluster >= 4:
suspicion = 25.0 + (cluster - 4) * (35.0 / 4)
else:
suspicion = cluster * 6.0 # 0-3 matches -> low score
return max(0.0, min(100.0, suspicion))
except Exception:
return None