Download route/clone_detection_module.py from Manar55/truscan: direct link, hf CLI and curl.
- Browser
- Download file 4.78 kB
-
https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/clone_detection_module.py
- Command line
-
hf download hf://spaces/Manar55/truscan/route/clone_detection_module.py
-
curl -L -o clone_detection_module.py https://huggingface.co/spaces/Manar55/truscan/resolve/main/route/clone_detection_module.py
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 | |