Spaces:
Sleeping
Sleeping
File size: 6,315 Bytes
d70361b | 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 | """KPCAMNet-style unsupervised deep change features (Kernel-PCA convolution).
Implements the core idea of "Unsupervised Change Detection in Multitemporal
VHR Images Based on Deep Kernel PCA Convolutional Mapping Network"
(Wu, Chen et al., IEEE TCYB 2022, https://github.com/ChenHongruixuan/KPCAMNet):
1. Patch vectors from both timestamps are projected through *shared* kernel-PCA
mappings (siamese: identical projection for both images), stacked in layers
like a small convolutional network. No labels or pretraining required —
projections are fitted on the image pair itself.
2. The per-pixel feature difference is mapped to a polar domain:
magnitude ``rho`` (change strength) and direction ``theta`` (change type).
3. ``rho`` is thresholded (Otsu + hysteresis by the caller) for the binary
change mask; ``theta`` supports discriminating change types downstream.
For CPU practicality the exact KPCA (O(N^2) kernel matrix) is replaced by the
standard Nystroem approximation + linear PCA, which scales linearly in pixels
while preserving the kernelized mapping. Layer projections are fitted on a
random pixel sample from BOTH images so the mapping is shared (siamese).
"""
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__)
@dataclass
class KPCAChangeResult:
rho: np.ndarray # float32 [0,1], change magnitude, input resolution
theta: np.ndarray # float32 [-pi,pi], change direction, input resolution
n_layers: int
n_components: int
analysis_shape: Tuple[int, int]
def _patch_stack(img: np.ndarray, patch: int) -> np.ndarray:
"""(H,W,C) -> (H,W,C*patch*patch) neighborhood features via shifted views."""
h, w, c = img.shape
r = patch // 2
padded = cv2.copyMakeBorder(img, r, r, r, r, cv2.BORDER_REFLECT)
views = []
for dy in range(patch):
for dx in range(patch):
views.append(padded[dy:dy + h, dx:dx + w])
return np.concatenate(views, axis=2).astype(np.float32)
def _fit_shared_projection(feat_a: np.ndarray, feat_b: np.ndarray,
n_components: int, sample: int, seed: int):
"""Fit Nystroem(RBF) + PCA on a pixel sample drawn from both images."""
from sklearn.decomposition import PCA
from sklearn.kernel_approximation import Nystroem
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
rng = np.random.default_rng(seed)
flat = np.concatenate([
feat_a.reshape(-1, feat_a.shape[2]),
feat_b.reshape(-1, feat_b.shape[2]),
], axis=0)
take = min(sample, flat.shape[0])
idx = rng.choice(flat.shape[0], size=take, replace=False)
fit_data = flat[idx]
n_landmarks = int(min(128, max(32, take // 24)))
pipe = Pipeline([
("scale", StandardScaler()),
("nystroem", Nystroem(kernel="rbf", n_components=n_landmarks, random_state=seed)),
("pca", PCA(n_components=n_components, random_state=seed)),
])
pipe.fit(fit_data)
return pipe
def _transform_image(pipe, feat: np.ndarray) -> np.ndarray:
h, w, c = feat.shape
out = pipe.transform(feat.reshape(-1, c))
return out.reshape(h, w, -1).astype(np.float32)
def compute_kpca_change(
img1: np.ndarray,
img2: np.ndarray,
*,
n_components: int = 8,
n_layers: int = 2,
patch: int = 5,
max_side: int = 768,
fit_sample: int = 4000,
seed: int = 42,
) -> Optional[KPCAChangeResult]:
"""Siamese KPCA change features for an RGB image pair.
Returns rho/theta maps at the input resolution, or None on failure
(caller falls back to its previous behavior).
"""
try:
if img1.shape != img2.shape or img1.ndim != 3:
return None
full_h, full_w = img1.shape[:2]
# Bounded analysis resolution keeps the Nystroem transform CPU-friendly
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:
a, b = img1, img2
a = a.astype(np.float32) / 255.0
b = b.astype(np.float32) / 255.0
feat_a, feat_b = a, b
layer_patch = patch
for layer in range(max(1, n_layers)):
stack_a = _patch_stack(feat_a, layer_patch)
stack_b = _patch_stack(feat_b, layer_patch)
pipe = _fit_shared_projection(
stack_a, stack_b, n_components=n_components,
sample=fit_sample, seed=seed + layer,
)
feat_a = _transform_image(pipe, stack_a)
feat_b = _transform_image(pipe, stack_b)
layer_patch = 3 # deeper layers use tighter neighborhoods
diff = feat_a - feat_b
# Polar mapping (KPCAMNet): magnitude over all components; direction
# from the two most-informative difference components.
rho = np.sqrt(np.sum(diff * diff, axis=2))
var_order = np.argsort(np.var(diff.reshape(-1, diff.shape[2]), axis=0))[::-1]
d1 = diff[:, :, var_order[0]]
d2 = diff[:, :, var_order[1]] if diff.shape[2] > 1 else np.zeros_like(d1)
theta = np.arctan2(d2, d1)
# Robust [0,1] normalization of magnitude
hi = float(np.quantile(rho, 0.995))
if hi <= 1e-8:
hi = float(rho.max() + 1e-8)
rho = np.clip(rho / hi, 0.0, 1.0).astype(np.float32)
if rho.shape != (full_h, full_w):
rho = cv2.resize(rho, (full_w, full_h), interpolation=cv2.INTER_LINEAR)
theta = cv2.resize(theta.astype(np.float32), (full_w, full_h),
interpolation=cv2.INTER_NEAREST)
return KPCAChangeResult(
rho=rho.astype(np.float32),
theta=theta.astype(np.float32),
n_layers=max(1, n_layers),
n_components=n_components,
analysis_shape=(a.shape[0], a.shape[1]),
)
except Exception as exc:
_log.warning("KPCA change features failed (%s) — caller will fall back", exc)
return None
|