File size: 12,614 Bytes
3ba5b62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
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
    }