""" 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