Spaces:
Sleeping
Sleeping
| """IR-MAD: Iteratively Reweighted Multivariate Alteration Detection. | |
| Classical unsupervised change detection statistic (Nielsen 2007), listed among | |
| the strongest traditional methods in the Change-Detection-Review repository | |
| (https://github.com/MinZHANG-WHU/Change-Detection-Review). | |
| MAD finds paired linear combinations of the two images' bands (via canonical | |
| correlation analysis) whose differences maximally decorrelate; the chi-square | |
| sum of squared standardized MAD variates measures change. The IR step | |
| re-estimates the statistics using no-change weights so genuine changes don't | |
| bias the transform, sharpening separation between change and no-change. | |
| Pure numpy — no extra dependencies. Runs on a bounded-resolution copy for CPU | |
| practicality and returns both the change probability map and the final | |
| no-change weights (useful for regression-based radiometric normalization). | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass | |
| from typing import Optional, Tuple | |
| import cv2 | |
| import numpy as np | |
| _log = logging.getLogger(__name__) | |
| class IRMADResult: | |
| chi2: np.ndarray # float32, raw chi-square statistic (analysis res) | |
| change_prob: np.ndarray # float32 [0,1], at the input resolution | |
| no_change_weights: np.ndarray # float32 [0,1], at the input resolution | |
| iterations: int | |
| converged: bool | |
| def _weighted_stats(x: np.ndarray, y: np.ndarray, w: np.ndarray): | |
| """Weighted means and joint covariance of two multiband pixel matrices.""" | |
| wsum = float(np.sum(w)) + 1e-12 | |
| mx = (x * w[:, None]).sum(axis=0) / wsum | |
| my = (y * w[:, None]).sum(axis=0) / wsum | |
| xc = x - mx | |
| yc = y - my | |
| sxx = (xc * w[:, None]).T @ xc / wsum | |
| syy = (yc * w[:, None]).T @ yc / wsum | |
| sxy = (xc * w[:, None]).T @ yc / wsum | |
| return mx, my, sxx, syy, sxy, xc, yc | |
| def _chi2_cdf(x: np.ndarray, k: int) -> np.ndarray: | |
| """Chi-square CDF via the regularized lower incomplete gamma function.""" | |
| try: | |
| from scipy.special import gammainc | |
| return gammainc(k / 2.0, x / 2.0) | |
| except Exception: | |
| # Wilson–Hilferty normal approximation (good for k>=3) | |
| z = ((x / k) ** (1.0 / 3.0) - (1 - 2.0 / (9 * k))) / np.sqrt(2.0 / (9 * k)) | |
| return 0.5 * (1.0 + np.tanh(z * 0.79788456 * (1 + 0.044715 * z * z))) | |
| def compute_irmad( | |
| img1: np.ndarray, | |
| img2: np.ndarray, | |
| *, | |
| max_iters: int = 10, | |
| tol: float = 1e-3, | |
| max_side: int = 1024, | |
| eps: float = 1e-6, | |
| ) -> Optional[IRMADResult]: | |
| """Run IR-MAD on an RGB pair. Returns None on numerical failure.""" | |
| try: | |
| if img1.shape != img2.shape or img1.ndim != 3: | |
| return None | |
| full_h, full_w = img1.shape[:2] | |
| scale = min(1.0, max_side / max(full_h, full_w)) | |
| if scale < 1.0: | |
| ah, aw = max(64, int(full_h * scale)), max(64, int(full_w * scale)) | |
| a = cv2.resize(img1, (aw, ah), interpolation=cv2.INTER_AREA) | |
| b = cv2.resize(img2, (aw, ah), interpolation=cv2.INTER_AREA) | |
| else: | |
| ah, aw = full_h, full_w | |
| a, b = img1, img2 | |
| bands = a.shape[2] | |
| x = a.reshape(-1, bands).astype(np.float64) | |
| y = b.reshape(-1, bands).astype(np.float64) | |
| n = x.shape[0] | |
| w = np.ones(n, dtype=np.float64) | |
| rho_prev = None | |
| converged = False | |
| it = 0 | |
| mads_std = np.zeros_like(x) | |
| for it in range(1, max_iters + 1): | |
| _, _, sxx, syy, sxy, xc, yc = _weighted_stats(x, y, w) | |
| sxx += eps * np.eye(bands) | |
| syy += eps * np.eye(bands) | |
| # CCA via generalized eigenproblem on sxx^-1 sxy syy^-1 syx | |
| isxx = np.linalg.inv(sxx) | |
| isyy = np.linalg.inv(syy) | |
| m1 = isxx @ sxy @ isyy @ sxy.T | |
| evals, evecs = np.linalg.eig(m1) | |
| order = np.argsort(evals.real)[::-1] | |
| rho2 = np.clip(evals.real[order], 0.0, 1.0) | |
| avecs = evecs.real[:, order] | |
| # Normalize canonical vectors: var(a^T x) = 1 | |
| for j in range(bands): | |
| va = avecs[:, j] @ sxx @ avecs[:, j] | |
| avecs[:, j] /= np.sqrt(max(va, eps)) | |
| bvecs = isyy @ sxy.T @ avecs | |
| for j in range(bands): | |
| vb = bvecs[:, j] @ syy @ bvecs[:, j] | |
| bvecs[:, j] /= np.sqrt(max(vb, eps)) | |
| u = xc @ avecs | |
| v = yc @ bvecs | |
| # Sign alignment: positive correlation between pairs | |
| for j in range(bands): | |
| if np.sum(u[:, j] * v[:, j] * w) < 0: | |
| bvecs[:, j] = -bvecs[:, j] | |
| v[:, j] = -v[:, j] | |
| mads = u - v | |
| rho = np.sqrt(rho2) | |
| sigma2 = np.maximum(2.0 * (1.0 - rho), eps) # MAD variances | |
| mads_std = mads / np.sqrt(sigma2)[None, :] | |
| chi2 = np.sum(mads_std ** 2, axis=1) | |
| # No-change probability = 1 - CDF (large chi2 => change) | |
| w_new = 1.0 - _chi2_cdf(chi2, bands) | |
| w_new = np.clip(w_new, 1e-6, 1.0) | |
| if rho_prev is not None and np.max(np.abs(rho - rho_prev)) < tol: | |
| w = w_new | |
| converged = True | |
| break | |
| rho_prev = rho | |
| w = w_new | |
| chi2_map = np.sum(mads_std ** 2, axis=1).reshape(ah, aw) | |
| # Robust quantile normalization instead of the chi-square CDF: the CDF | |
| # saturates at ~1.0 for most real pixels, which destroys the graded | |
| # response needed by percentile-threshold fusion downstream. | |
| hi = float(np.quantile(chi2_map, 0.995)) | |
| if hi <= 1e-8: | |
| hi = float(chi2_map.max() + 1e-8) | |
| prob = np.clip(chi2_map / hi, 0.0, 1.0) | |
| nc_w = w.reshape(ah, aw) | |
| prob_full = prob.astype(np.float32) | |
| ncw_full = nc_w.astype(np.float32) | |
| if (ah, aw) != (full_h, full_w): | |
| prob_full = cv2.resize(prob_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR) | |
| ncw_full = cv2.resize(ncw_full, (full_w, full_h), interpolation=cv2.INTER_LINEAR) | |
| return IRMADResult( | |
| chi2=chi2_map.astype(np.float32), | |
| change_prob=np.clip(prob_full, 0.0, 1.0), | |
| no_change_weights=np.clip(ncw_full, 0.0, 1.0), | |
| iterations=it, | |
| converged=converged, | |
| ) | |
| except Exception as exc: | |
| _log.warning("IR-MAD failed (%s)", exc) | |
| return None | |
| def radiometric_regression_normalize( | |
| img1: np.ndarray, | |
| img2: np.ndarray, | |
| no_change_weights: np.ndarray, | |
| min_weight: float = 0.5, | |
| ) -> Optional[np.ndarray]: | |
| """Normalize img2 to img1 via weighted linear regression on no-change pixels. | |
| Classic IR-MAD application: fit per-band ``img1 ~ a*img2 + b`` using only | |
| pixels the IR-MAD iteration deemed unchanged, then map img2 through the | |
| fit. More faithful than global mean/std matching because changed pixels | |
| no longer skew the statistics. Returns uint8 image or None. | |
| """ | |
| try: | |
| if img1.shape != img2.shape: | |
| return None | |
| w = no_change_weights.astype(np.float64).ravel() | |
| sel = w >= min_weight | |
| if np.count_nonzero(sel) < 500: | |
| return None | |
| out = img2.astype(np.float64).copy() | |
| for ch in range(img1.shape[2]): | |
| s = img2[:, :, ch].astype(np.float64).ravel()[sel] | |
| t = img1[:, :, ch].astype(np.float64).ravel()[sel] | |
| ww = w[sel] | |
| wsum = ww.sum() + 1e-12 | |
| ms, mt = (s * ww).sum() / wsum, (t * ww).sum() / wsum | |
| cov = ((s - ms) * (t - mt) * ww).sum() / wsum | |
| var = ((s - ms) ** 2 * ww).sum() / wsum | |
| if var < 1e-6: | |
| continue | |
| gain = cov / var | |
| if not (0.2 <= gain <= 5.0): | |
| continue | |
| offset = mt - gain * ms | |
| out[:, :, ch] = img2[:, :, ch].astype(np.float64) * gain + offset | |
| return np.clip(out, 0, 255).astype(np.uint8) | |
| except Exception as exc: | |
| _log.warning("IR-MAD regression normalization failed (%s)", exc) | |
| return None | |