Spaces:
Sleeping
Sleeping
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
} |