Spaces:
Sleeping
Sleeping
| import cv2 | |
| import numpy as np | |
| from skimage.metrics import structural_similarity as ssim | |
| # ----------------------------- | |
| # LPIPS β perceptual similarity (lazy-loaded so first request pays the cost) | |
| # ----------------------------- | |
| _lpips_model = None | |
| _lpips_torch = None | |
| def _get_lpips(): | |
| """Lazy-load LPIPS + torch on first use. Returns (model, torch) or (None, None) | |
| if the dependencies aren't installed β pipeline still works without it.""" | |
| global _lpips_model, _lpips_torch | |
| if _lpips_model is not None: | |
| return _lpips_model, _lpips_torch | |
| try: | |
| import torch | |
| import lpips | |
| _lpips_torch = torch | |
| # 'alex' is the fastest backbone; 'vgg' is more accurate but slower. | |
| _lpips_model = lpips.LPIPS(net='alex', verbose=False).eval() | |
| except Exception: | |
| _lpips_model = False # sentinel: tried and failed | |
| _lpips_torch = None | |
| return _lpips_model, _lpips_torch | |
| def lpips_distance(patch1_bgr, patch2_bgr): | |
| """Returns LPIPS perceptual distance in [0, ~1]. Higher = more different. | |
| Returns None if LPIPS isn't available.""" | |
| model, torch = _get_lpips() | |
| if not model: | |
| return None | |
| def to_tensor(bgr): | |
| rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) | |
| # LPIPS expects 64x64+ float in [-1, 1], shape (1,3,H,W) | |
| rgb = cv2.resize(rgb, (128, 128)) | |
| t = torch.from_numpy(rgb).float().permute(2, 0, 1) / 127.5 - 1.0 | |
| return t.unsqueeze(0) | |
| with torch.no_grad(): | |
| d = model(to_tensor(patch1_bgr), to_tensor(patch2_bgr)) | |
| return float(d.item()) | |
| # ----------------------------- | |
| # ALIGNMENT (ORB homography + ECC sub-pixel refinement) | |
| # ----------------------------- | |
| def align_images(img1, img2): | |
| gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) | |
| gray2 = cv2.cvtColor(img2, cv2.COLOR_BGR2GRAY) | |
| orb = cv2.ORB_create(3000) | |
| kp1, des1 = orb.detectAndCompute(gray1, None) | |
| kp2, des2 = orb.detectAndCompute(gray2, None) | |
| aligned = img2 # fallback | |
| if des1 is not None and des2 is not None: | |
| matcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True) | |
| matches = matcher.match(des1, des2) | |
| if len(matches) >= 10: | |
| matches = sorted(matches, key=lambda x: x.distance)[:50] | |
| pts1 = np.float32([kp1[m.queryIdx].pt for m in matches]) | |
| pts2 = np.float32([kp2[m.trainIdx].pt for m in matches]) | |
| matrix, _ = cv2.findHomography(pts2, pts1, cv2.RANSAC) | |
| if matrix is not None: | |
| aligned = cv2.warpPerspective( | |
| img2, matrix, (img1.shape[1], img1.shape[0]) | |
| ) | |
| # ---- ECC refinement, self-validating ---- | |
| # Only keep the ECC result if it actually IMPROVED alignment | |
| # (measured by SSIM of the aligned images). Otherwise discard. | |
| try: | |
| g1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) | |
| g2_before = cv2.cvtColor(aligned, cv2.COLOR_BGR2GRAY) | |
| before_score, _ = ssim(g1, g2_before, full=True) | |
| warp = np.eye(3, 3, dtype=np.float32) | |
| criteria = ( | |
| cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, | |
| 100, | |
| 1e-5, | |
| ) | |
| _, warp = cv2.findTransformECC( | |
| g1, g2_before, warp, cv2.MOTION_HOMOGRAPHY, criteria, None, 5 | |
| ) | |
| candidate = cv2.warpPerspective( | |
| aligned, | |
| warp, | |
| (img1.shape[1], img1.shape[0]), | |
| flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP, | |
| ) | |
| g2_after = cv2.cvtColor(candidate, cv2.COLOR_BGR2GRAY) | |
| after_score, _ = ssim(g1, g2_after, full=True) | |
| if after_score > before_score: | |
| aligned = candidate | |
| except cv2.error: | |
| # ECC can fail to converge β keep ORB-only result in that case | |
| pass | |
| return aligned | |
| # ----------------------------- | |
| # SSIM-BASED STRUCTURAL DIFF | |
| # ----------------------------- | |
| def ssim_diff(gray1, gray2): | |
| """Returns a uint8 'difference' map where bright = structurally different.""" | |
| score, diff_map = ssim(gray1, gray2, full=True) | |
| # ssim returns [-1, 1] where 1 == identical; invert + scale to 0..255 | |
| diff_map = (1.0 - diff_map) * 255.0 | |
| return score, diff_map.astype(np.uint8) | |
| # ----------------------------- | |
| # CRACK DETECTION (IMPROVED) | |
| # ----------------------------- | |
| def detect_cracks(gray_img): | |
| enhanced = cv2.equalizeHist(gray_img) | |
| edges = cv2.Canny(enhanced, 40, 120) | |
| kernel = np.ones((3,3), np.uint8) | |
| edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=1) | |
| return edges | |
| # ----------------------------- | |
| # MAIN FUNCTION | |
| # ----------------------------- | |
| def compare_images(img1_path, img2_path, output_path, heatmap_path): | |
| img1 = cv2.imread(img1_path) | |
| img2 = cv2.imread(img2_path) | |
| img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0])) | |
| # π₯ ALIGN | |
| aligned_img2 = align_images(img1, img2) | |
| gray1 = cv2.cvtColor(img1, cv2.COLOR_BGR2GRAY) | |
| gray2 = cv2.cvtColor(aligned_img2, cv2.COLOR_BGR2GRAY) | |
| # Moderate blur β enough to absorb pixel noise / alignment jitter, but | |
| # small enough that handwritten letters and other tiny changes survive. | |
| blur1 = cv2.GaussianBlur(gray1, (5, 5), 0) | |
| blur2 = cv2.GaussianBlur(gray2, (5, 5), 0) | |
| # ----------------------------- | |
| # DIFF β SSIM with a large window is the workhorse here. | |
| # A big win=21 window means a single new pixel doesn't trigger a hit; | |
| # only regions where the *local structure* genuinely changed do. | |
| # ----------------------------- | |
| ssim_score, struct_diff = ssim_diff(blur1, blur2) | |
| diff = struct_diff | |
| heatmap = cv2.applyColorMap(diff, cv2.COLORMAP_JET) | |
| # ----------------------------- | |
| # OBJECT DETECTION β shape-aware filtering | |
| # ----------------------------- | |
| _, thresh = cv2.threshold(diff, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| # OPEN first: erodes thin alignment-edge ribbons away entirely. | |
| # Then CLOSE: reconnects fragments of the real new object. | |
| # Smaller OPEN kernel preserves small changes (handwritten letters, marks). | |
| # Median blur first β single best speckle killer for textured surfaces | |
| # (wood grain, carpet, asphalt) where SSIM picks up tiny texture jitter. | |
| thresh = cv2.medianBlur(thresh, 7) | |
| open_k = np.ones((7, 7), np.uint8) | |
| close_k = np.ones((15, 15), np.uint8) | |
| thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, open_k, iterations=2) | |
| thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, close_k, iterations=2) | |
| result = aligned_img2.copy() | |
| img_area = gray1.shape[0] * gray1.shape[1] | |
| # ---- Global alignment-failure guard ---- | |
| # If the diff covers a huge portion of the image, alignment is broken | |
| # (camera moved/rotated too much between shots). Bail out cleanly | |
| # instead of drawing one giant useless box. | |
| diff_ratio = cv2.countNonZero(thresh) / img_area | |
| alignment_failed = diff_ratio > 0.70 | |
| contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| if alignment_failed: | |
| contours = [] # skip detection entirely | |
| # Allow tiny changes (single letters, marks). LPIPS verification below | |
| # is what keeps false positives out at this size. | |
| min_area = max(400, img_area * 0.0002) # ~0.02% of image | |
| # Score each contour and only keep "blob-shaped" ones (real objects), | |
| # not "ribbon-shaped" ones (edge jitter). | |
| candidates = [] | |
| for cnt in contours: | |
| area = cv2.contourArea(cnt) | |
| if area < min_area: | |
| continue | |
| x, y, w, h = cv2.boundingRect(cnt) | |
| # Solidity = contour area / convex hull area. Real objects β 0.6+. | |
| # Thin ribbons along edges have very low solidity. | |
| hull_area = cv2.contourArea(cv2.convexHull(cnt)) | |
| solidity = area / hull_area if hull_area > 0 else 0 | |
| # Extent = contour area / bounding-box area. Slivers β low extent. | |
| extent = area / (w * h) if w * h > 0 else 0 | |
| # Aspect ratio sanity: skip extreme slivers | |
| aspect = max(w, h) / max(1, min(w, h)) | |
| # LARGE-BLOB BYPASS: any region covering >2% of the image is | |
| # almost certainly a real change (added/removed object). Trust it | |
| # unconditionally β don't apply shape filters that could reject | |
| # irregular shapes (e.g. paper partially occluded by a plant). | |
| is_large = area > img_area * 0.02 | |
| # Relaxed thresholds β small handwritten marks can be irregular. | |
| # LPIPS does the final perceptual gate. | |
| if not is_large and (solidity < 0.30 or extent < 0.18 or aspect > 12): | |
| continue | |
| candidates.append((area, x, y, w, h)) | |
| # Keep only the top few biggest blobs β the "real" changes. | |
| candidates.sort(reverse=True) | |
| candidates = candidates[:15] # raise cap; LPIPS will prune below | |
| # ----------------------------- | |
| # LPIPS PERCEPTUAL VERIFICATION | |
| # For each surviving candidate region, check that the patch in img2 is | |
| # *perceptually* different from the same region in img1. This kills the | |
| # last layer of false positives (lighting shifts, residual jitter on | |
| # textured surfaces) that survived the SSIM + shape filter. | |
| # ----------------------------- | |
| LPIPS_THRESHOLD = 0.15 # tuned: <0.10 = ~identical, >0.20 = clearly different | |
| verified = [] | |
| lpips_scores = [] | |
| for area, x, y, w, h in candidates: | |
| # Pad the crop slightly so LPIPS sees context, not just the object edge | |
| pad = 10 | |
| x0 = max(0, x - pad) | |
| y0 = max(0, y - pad) | |
| x1 = min(img1.shape[1], x + w + pad) | |
| y1 = min(img1.shape[0], y + h + pad) | |
| patch1 = img1[y0:y1, x0:x1] | |
| patch2 = aligned_img2[y0:y1, x0:x1] | |
| if patch1.size == 0 or patch2.size == 0: | |
| continue | |
| d = lpips_distance(patch1, patch2) | |
| # If LPIPS is unavailable (None), trust the upstream pipeline and keep. | |
| # If available, only keep candidates that are perceptually different. | |
| if d is None or d >= LPIPS_THRESHOLD: | |
| verified.append((area, x, y, w, h)) | |
| if d is not None: | |
| lpips_scores.append(d) | |
| object_changes = len(verified) | |
| for _, x, y, w, h in verified: | |
| cv2.rectangle(result, (x, y), (x + w, y + h), (0, 0, 255), 3) | |
| # ----------------------------- | |
| # CRACK DETECTION (ONLY IN CHANGED AREA) | |
| # ----------------------------- | |
| cracks = detect_cracks(gray2) | |
| # π₯ FIX 4 APPLIED | |
| cracks = cv2.bitwise_and(cracks, cracks, mask=thresh) | |
| crack_contours, _ = cv2.findContours(cracks, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| crack_count = 0 | |
| total_crack_area = 0 | |
| max_crack_area = 0 | |
| # Only run crack analysis if NO new objects were detected. When a real | |
| # object appears, every edge of it would otherwise be flagged as a "crack". | |
| if object_changes == 0: | |
| for cnt in crack_contours: | |
| area = cv2.contourArea(cnt) | |
| if 80 < area < 1500: | |
| x, y, w, h = cv2.boundingRect(cnt) | |
| if w < 60 and h < 60: | |
| crack_count += 1 | |
| total_crack_area += area | |
| max_crack_area = max(max_crack_area, area) | |
| cv2.rectangle(result, (x, y), (x+w, y+h), (255,0,0), 2) | |
| # ----------------------------- | |
| # SEVERITY SCORING | |
| # >=1 real object change β HIGH. Otherwise β NONE. | |
| # ----------------------------- | |
| if object_changes >= 1 or crack_count >= 1: | |
| severity = "HIGH" | |
| else: | |
| severity = "NONE" | |
| # ----------------------------- | |
| # DRAW LABEL | |
| # ----------------------------- | |
| color = (0,255,0) | |
| if severity == "MEDIUM": | |
| color = (0,255,255) | |
| elif severity == "HIGH": | |
| color = (0,0,255) | |
| label = f"Severity: {severity}" | |
| if alignment_failed: | |
| label = "ALIGNMENT FAILED β retake from same angle" | |
| color = (0, 165, 255) | |
| cv2.putText(result, label, (20, 40), | |
| cv2.FONT_HERSHEY_SIMPLEX, 1, color, 3) | |
| # Save outputs | |
| cv2.imwrite(output_path, result) | |
| cv2.imwrite(heatmap_path, heatmap) | |
| return { | |
| "crack_count": crack_count, | |
| "total_crack_area": int(total_crack_area), | |
| "max_crack_area": int(max_crack_area), | |
| "object_changes": object_changes, | |
| "ssim_score": float(ssim_score), | |
| "lpips_max": float(max(lpips_scores)) if lpips_scores else None, | |
| "lpips_available": lpips_scores != [] or _get_lpips()[0] is not False, | |
| "alignment_failed": bool(alignment_failed), | |
| "severity": severity | |
| } |