satdetect / app /detection_engine.py
coderuday21's picture
Full codebase audit: fix critical perf bug, security, error handling, dead code
0bf1136
raw
history blame
58.3 kB
"""
Satellite Change Detection Engine v2
High-accuracy detection with multi-channel analysis, SSIM, texture features,
adaptive thresholding, and improved object classification.
"""
import numpy as np
import cv2
from PIL import Image
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from collections import Counter
# ---------------------------------------------------------------------------
# 1. Pre-processing
# ---------------------------------------------------------------------------
def preprocess_image(image):
"""Preprocess image: convert to RGB, limit size."""
img_array = np.array(image)
if img_array.ndim == 2:
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
elif img_array.ndim == 3 and img_array.shape[2] == 4:
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
elif img_array.ndim != 3 or img_array.shape[2] != 3:
raise ValueError(f"Unsupported image shape: {img_array.shape}")
max_size = 2000
height, width = img_array.shape[:2]
if max(height, width) > max_size:
scale = max_size / max(height, width)
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
return img_array
# ---------------------------------------------------------------------------
# 2. Improved image registration (alignment)
# ---------------------------------------------------------------------------
def register_images(img1, img2, max_features=2000):
"""Align img2 to img1 using ORB + ratio-test + RANSAC homography."""
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
orb = cv2.ORB_create(nfeatures=max_features, scoreType=cv2.ORB_HARRIS_SCORE)
kp1, des1 = orb.detectAndCompute(gray1, None)
kp2, des2 = orb.detectAndCompute(gray2, None)
if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
return img1, img2, False
# Use kNN matching with Lowe's ratio test for better matches
bf = cv2.BFMatcher(cv2.NORM_HAMMING)
raw_matches = bf.knnMatch(des1, des2, k=2)
good_matches = []
for pair in raw_matches:
if len(pair) == 2:
m, n = pair
if m.distance < 0.75 * n.distance:
good_matches.append(m)
if len(good_matches) < 10:
return img1, img2, False
src_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
dst_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
homography, mask = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 3.0)
if homography is None:
return img1, img2, False
inlier_ratio = np.sum(mask) / len(mask) if mask is not None else 0
if inlier_ratio < 0.3:
return img1, img2, False
# Reject degenerate homographies (near-singular or extreme distortion)
det = np.linalg.det(homography)
if abs(det) < 0.1 or abs(det) > 10.0:
return img1, img2, False
h, w = img1.shape[:2]
img2_aligned = cv2.warpPerspective(img2, homography, (w, h), borderMode=cv2.BORDER_REFLECT)
return img1, img2_aligned, True
# ---------------------------------------------------------------------------
# 3. Improved radiometric normalization
# ---------------------------------------------------------------------------
def normalize_radiometry(img1, img2):
"""Histogram-matching normalization in LAB space. CLAHE applied symmetrically."""
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
result = lab2.copy()
for ch in range(3):
mean1, std1 = np.mean(lab1[:, :, ch]), np.std(lab1[:, :, ch])
mean2, std2 = np.mean(lab2[:, :, ch]), np.std(lab2[:, :, ch])
if std2 > 1e-6:
result[:, :, ch] = (lab2[:, :, ch] - mean2) * (std1 / std2) + mean1
result_uint8 = np.clip(result, 0, 255).astype(np.uint8)
# CLAHE on L channel of BOTH images so downstream comparison is symmetric
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
lab1_uint8 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB)
lab1_uint8[:, :, 0] = clahe.apply(lab1_uint8[:, :, 0])
result_uint8[:, :, 0] = clahe.apply(result_uint8[:, :, 0])
img1_out = cv2.cvtColor(lab1_uint8, cv2.COLOR_LAB2RGB)
img2_out = cv2.cvtColor(result_uint8, cv2.COLOR_LAB2RGB)
return img1_out, img2_out
# ---------------------------------------------------------------------------
# 4. SSIM-based structural change map
# ---------------------------------------------------------------------------
def compute_ssim_change_map(img1, img2, win_size=7):
"""Compute per-pixel structural dissimilarity (1 - SSIM)."""
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float64)
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float64)
C1 = (0.01 * 255) ** 2
C2 = (0.03 * 255) ** 2
mu1 = cv2.GaussianBlur(gray1, (win_size, win_size), 1.5)
mu2 = cv2.GaussianBlur(gray2, (win_size, win_size), 1.5)
mu1_sq = mu1 * mu1
mu2_sq = mu2 * mu2
mu1_mu2 = mu1 * mu2
# Clamp to zero: E[X²]-E[X]² can go slightly negative from float rounding
sigma1_sq = np.maximum(cv2.GaussianBlur(gray1 * gray1, (win_size, win_size), 1.5) - mu1_sq, 0)
sigma2_sq = np.maximum(cv2.GaussianBlur(gray2 * gray2, (win_size, win_size), 1.5) - mu2_sq, 0)
sigma12 = cv2.GaussianBlur(gray1 * gray2, (win_size, win_size), 1.5) - mu1_mu2
denom = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2)
ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / (denom + 1e-12)
# Structural dissimilarity: 0 = identical, 1 = completely different
dssim = np.clip((1.0 - ssim_map) / 2.0, 0, 1)
return dssim
# ---------------------------------------------------------------------------
# 5. Texture feature extraction (LBP)
# ---------------------------------------------------------------------------
def compute_lbp(gray, radius=1, n_points=8):
"""Compute simplified Local Binary Pattern texture descriptor."""
h, w = gray.shape
lbp = np.zeros_like(gray, dtype=np.float32)
for i in range(n_points):
angle = 2 * np.pi * i / n_points
dx = int(round(radius * np.cos(angle)))
dy = int(round(-radius * np.sin(angle)))
shifted = np.roll(np.roll(gray, dy, axis=0), dx, axis=1)
lbp += (shifted >= gray).astype(np.float32)
return lbp / n_points
def compute_texture_change(img1, img2):
"""Compute texture difference using LBP."""
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32)
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float32)
lbp1 = compute_lbp(gray1)
lbp2 = compute_lbp(gray2)
texture_diff = np.abs(lbp1 - lbp2)
return texture_diff
# ---------------------------------------------------------------------------
# 6. Edge-aware change detection
# ---------------------------------------------------------------------------
def compute_edge_change(img1, img2):
"""Compute edge-based change map using Canny edges."""
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
# Adaptive Canny thresholds based on median intensity
med1 = np.median(gray1)
edges1 = cv2.Canny(gray1, int(max(0, 0.67 * med1)), int(min(255, 1.33 * med1)))
med2 = np.median(gray2)
edges2 = cv2.Canny(gray2, int(max(0, 0.67 * med2)), int(min(255, 1.33 * med2)))
# Dilate edges slightly so nearby edges match
kernel = np.ones((3, 3), np.uint8)
edges1_d = cv2.dilate(edges1, kernel, iterations=1)
edges2_d = cv2.dilate(edges2, kernel, iterations=1)
# New edges = present in one image but not the other
edge_change = cv2.absdiff(edges1_d, edges2_d).astype(np.float32) / 255.0
return edge_change
# ---------------------------------------------------------------------------
# 7. Improved detection methods
# ---------------------------------------------------------------------------
def image_difference_method(img1, img2, threshold=0.25, blur_size=5):
"""Improved image difference with multi-channel analysis and adaptive threshold."""
if img1.shape != img2.shape:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
# Multi-channel difference in LAB (perceptually uniform)
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
lab1_blur = cv2.GaussianBlur(lab1, (blur_size, blur_size), 0)
lab2_blur = cv2.GaussianBlur(lab2, (blur_size, blur_size), 0)
# Weighted Delta-E inspired difference
diff = lab1_blur - lab2_blur
delta_e = np.sqrt(
(diff[:, :, 0] / 100.0) ** 2 +
(diff[:, :, 1] / 128.0) ** 2 +
(diff[:, :, 2] / 128.0) ** 2
)
delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
delta_uint8 = (delta_e * 255).astype(np.uint8)
otsu_val, change_mask = cv2.threshold(delta_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# Floor: if Otsu picks a very low threshold the mask is mostly noise
if otsu_val < 30:
_, change_mask = cv2.threshold(delta_uint8, 30, 255, cv2.THRESH_BINARY)
change_mask = _clean_mask(change_mask)
return change_mask
def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
"""Feature-based change detection using multi-space clustering."""
if img1.shape != img2.shape:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
hsv1 = cv2.cvtColor(img1, cv2.COLOR_RGB2HSV).astype(np.float32)
hsv2 = cv2.cvtColor(img2, cv2.COLOR_RGB2HSV).astype(np.float32)
diff_lab = np.abs(lab1 - lab2)
diff_hsv = np.abs(hsv1 - hsv2)
h, w, _ = diff_lab.shape
features = np.concatenate([diff_lab, diff_hsv[:, :, 1:]], axis=2)
# Downsample for KMeans (full-res is too slow for >1M pixels)
MAX_PIXELS = 250_000
total = h * w
if total > MAX_PIXELS:
scale = np.sqrt(MAX_PIXELS / total)
sh, sw = max(1, int(h * scale)), max(1, int(w * scale))
features_small = cv2.resize(features, (sw, sh))
else:
features_small = features
sh, sw = h, w
features_flat = features_small.reshape(-1, features_small.shape[2])
scaler = StandardScaler()
features_scaled = scaler.fit_transform(features_flat)
kmeans = KMeans(n_clusters=num_clusters, random_state=42, n_init=10)
labels_small = kmeans.fit_predict(features_scaled)
cluster_means = [
np.mean(np.linalg.norm(features_flat[labels_small == i], axis=1))
if np.any(labels_small == i) else 0.0
for i in range(num_clusters)
]
change_cluster_idx = np.argmax(cluster_means)
# Map labels back to full resolution by predicting on all pixels
if total > MAX_PIXELS:
full_flat = features.reshape(-1, features.shape[2])
full_scaled = scaler.transform(full_flat)
labels = kmeans.predict(full_scaled)
else:
labels = labels_small
change_mask = (labels == change_cluster_idx).astype(np.uint8) * 255
change_mask = change_mask.reshape(h, w)
change_mask = _clean_mask(change_mask, sensitivity)
return change_mask
def ai_deep_learning_method(img1, img2):
"""
Advanced multi-signal fusion:
- Multi-scale color difference (LAB)
- Structural dissimilarity (SSIM)
- Texture change (LBP)
- Edge change (Canny)
All fused with learned weights and adaptive thresholding.
"""
if img1.shape != img2.shape:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
# ---- Channel 1: Multi-scale LAB color difference ----
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
scales = [1, 2, 4]
color_maps = []
for scale in scales:
if scale > 1:
s1 = cv2.resize(lab1, (lab1.shape[1] // scale, lab1.shape[0] // scale))
s2 = cv2.resize(lab2, (lab2.shape[1] // scale, lab2.shape[0] // scale))
else:
s1, s2 = lab1, lab2
diff = s1 - s2
# Delta-E (CIE76) normalized
delta_e = np.sqrt((diff[:, :, 0] / 100.0) ** 2 +
(diff[:, :, 1] / 128.0) ** 2 +
(diff[:, :, 2] / 128.0) ** 2)
if scale > 1:
delta_e = cv2.resize(delta_e, (lab1.shape[1], lab1.shape[0]))
color_maps.append(delta_e)
color_change = np.mean(color_maps, axis=0)
color_change = color_change / (color_change.max() + 1e-8)
# ---- Channel 2: SSIM structural dissimilarity ----
ssim_change = compute_ssim_change_map(img1, img2)
ssim_change = ssim_change / (ssim_change.max() + 1e-8)
# ---- Channel 3: Texture change (LBP) ----
texture_change = compute_texture_change(img1, img2)
texture_change = texture_change / (texture_change.max() + 1e-8)
# ---- Channel 4: Edge change ----
edge_change = compute_edge_change(img1, img2)
# ---- Adaptive fusion ----
# Weight channels by their discriminative power (entropy-based)
channels = [color_change, ssim_change, texture_change, edge_change]
weights = []
for ch in channels:
ch_uint8 = (ch * 255).astype(np.uint8)
hist = cv2.calcHist([ch_uint8], [0], None, [256], [0, 256]).flatten()
hist = hist / (hist.sum() + 1e-8)
entropy = -np.sum(hist[hist > 0] * np.log2(hist[hist > 0] + 1e-10))
weights.append(entropy)
# Normalize weights
total_w = sum(weights) + 1e-8
weights = [w / total_w for w in weights]
# Fuse
fused = np.zeros_like(color_change, dtype=np.float64)
for ch, w in zip(channels, weights):
fused += w * ch.astype(np.float64)
fused = fused / (fused.max() + 1e-8)
fused_uint8 = (fused * 255).astype(np.uint8)
# Otsu with a minimum floor to reject near-zero thresholds on similar images
otsu_val, change_mask = cv2.threshold(fused_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
if otsu_val < 25:
_, change_mask = cv2.threshold(fused_uint8, 25, 255, cv2.THRESH_BINARY)
change_mask = _clean_mask(change_mask)
# Bilateral filter preserves sharp change boundaries while smoothing noise
change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
_, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
return change_mask
def hybrid_method(img1, img2):
"""Hybrid: weighted fusion of all methods with confidence-based merging."""
if img1.shape != img2.shape:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
diff_mask = image_difference_method(img1, img2)
feature_mask = feature_based_method(img1, img2)
ai_mask = ai_deep_learning_method(img1, img2)
# Weighted combination: AI method gets most weight
combined = (
0.2 * diff_mask.astype(np.float32) +
0.3 * feature_mask.astype(np.float32) +
0.5 * ai_mask.astype(np.float32)
)
# Use a higher threshold: a pixel must be flagged by multiple methods
_, final_mask = cv2.threshold(combined.astype(np.uint8), 140, 255, cv2.THRESH_BINARY)
final_mask = _clean_mask(final_mask)
return final_mask
# ---------------------------------------------------------------------------
# 8. Robust post-processing
# ---------------------------------------------------------------------------
def _clean_mask(mask, sensitivity=0.5, border_margin=12):
"""
Robust morphological cleaning:
1. Zero-out border pixels (registration artifacts)
2. Median filter to kill salt-and-pepper noise
3. Opening to remove small specks
4. Closing to bridge tiny gaps
5. Fill holes inside regions
6. Erode-then-dilate to break thin noise bridges between separate changes
"""
mask = mask.copy()
h, w = mask.shape[:2]
if border_margin > 0:
mask[:border_margin, :] = 0
mask[-border_margin:, :] = 0
mask[:, :border_margin] = 0
mask[:, -border_margin:] = 0
# 2. Median to remove isolated noise pixels
mask = cv2.medianBlur(mask, 5)
# 3. Opening (erosion then dilation) removes small specks
open_size = max(3, int(5 * (1 - sensitivity * 0.5)))
if open_size % 2 == 0:
open_size += 1
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
# 4. Closing to bridge small internal gaps
close_size = max(3, int(7 * (1 - sensitivity)))
if close_size % 2 == 0:
close_size += 1
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close)
# 5. Fill holes inside regions
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
filled = np.zeros_like(mask)
cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
# 6. Erode to break thin noise bridges, then dilate back
k_break = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
filled = cv2.erode(filled, k_break, iterations=1)
filled = cv2.dilate(filled, k_break, iterations=1)
return filled
# ---------------------------------------------------------------------------
# 9. Severity classification and improved visualization
# ---------------------------------------------------------------------------
def _severity_from_region(region, total_pixels):
"""
Classify change severity from area and confidence.
Green = minor, Yellow = moderate, Red = major.
Area is the primary signal; confidence acts as a small bonus.
"""
area = region.get("area", 0)
confidence = region.get("confidence", 0.0)
if total_pixels <= 0:
return "minor"
area_ratio = area / total_pixels
# Area-dominant score: area ratio (0-1) mapped to 0-10, confidence adds 0-0.3
score = area_ratio * 1000 + confidence * 0.3
if score < 1.0:
return "minor"
if score < 4.0:
return "moderate"
return "major"
# BGR colors for severity (OpenCV uses BGR)
_SEVERITY_COLORS = {
"minor": (0, 200, 0), # Green
"moderate": (0, 255, 255), # Yellow
"major": (0, 0, 255), # Red
}
def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
"""
Overlay change mask on 'after' image; draw color-coded bounding boxes
by severity (green=minor, yellow=moderate, red=major) and numbered labels.
"""
if img1.shape != img2.shape:
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
if change_mask.shape[:2] != img2.shape[:2]:
change_mask = cv2.resize(change_mask, (img2.shape[1], img2.shape[0]))
overlay = img2.copy().astype(np.float32)
mask_bool = change_mask > 127
mask_float = mask_bool.astype(np.float32)
# Lighter red overlay (35% alpha) so the image stays readable
red_layer = np.zeros_like(img2, dtype=np.float32)
red_layer[:, :, 0] = 255
alpha = 0.35
for c in range(3):
overlay[:, :, c] = (overlay[:, :, c] * (1 - mask_float * alpha)
+ red_layer[:, :, c] * mask_float * alpha)
overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
if regions:
diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
line_thickness = max(2, int(diag / 400))
for r in regions:
x, y, w, h = r["bbox"]
severity = r.get("severity") or _severity_from_region(r, total_px)
color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
# Semi-transparent fill using only the ROI (avoids full-image copy)
x1c = max(0, x)
y1c = max(0, y)
x2c = min(overlay_uint8.shape[1], x + w)
y2c = min(overlay_uint8.shape[0], y + h)
roi = overlay_uint8[y1c:y2c, x1c:x2c]
fill = np.full_like(roi, color, dtype=np.uint8)
cv2.addWeighted(fill, 0.12, roi, 0.88, 0, roi)
cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
rid = r.get("id", 0)
label = str(rid)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = max(0.45, min(0.8, w / 120))
thickness = max(1, line_thickness - 1)
(tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
lx = x
ly = max(th + 6, y - 6)
cv2.rectangle(overlay_uint8,
(lx, ly - th - 6), (lx + tw + 10, ly + 2),
color, cv2.FILLED)
cv2.putText(overlay_uint8, label, (lx + 5, ly - 2),
font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
return overlay_uint8
# ---------------------------------------------------------------------------
# 10. Improved object classification
# ---------------------------------------------------------------------------
def extract_advanced_features(region):
"""Extract rich features for classification: color, texture, edge, shape."""
if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3:
return None
hsv = cv2.cvtColor(region, cv2.COLOR_RGB2HSV)
lab = cv2.cvtColor(region, cv2.COLOR_RGB2LAB)
gray = cv2.cvtColor(region, cv2.COLOR_RGB2GRAY).astype(np.float32)
# Color stats
mean_rgb = np.mean(region, axis=(0, 1))
std_rgb = np.std(region, axis=(0, 1))
mean_hsv = np.mean(hsv, axis=(0, 1))
mean_lab = np.mean(lab, axis=(0, 1))
total_rgb = np.sum(mean_rgb) + 1e-6
green_ratio = mean_rgb[1] / total_rgb
blue_ratio = mean_rgb[2] / total_rgb
red_ratio = mean_rgb[0] / total_rgb
# Vegetation indices
ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
# Texture
texture_std = float(np.std(gray))
lbp = compute_lbp(gray.astype(np.float32))
lbp_variance = float(np.var(lbp))
# Edges
grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
grad_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
edge_mag = np.sqrt(grad_x ** 2 + grad_y ** 2)
edge_density = float(np.mean(edge_mag))
# Edge orientation histogram (structural regularity)
angles = np.arctan2(grad_y, grad_x + 1e-8)
angle_hist, _ = np.histogram(angles, bins=8, range=(-np.pi, np.pi))
angle_hist = angle_hist / (angle_hist.sum() + 1e-8)
orientation_entropy = -np.sum(angle_hist[angle_hist > 0] * np.log2(angle_hist[angle_hist > 0] + 1e-10))
# GLCM-like contrast (simplified: variance of neighbors)
shifted_r = np.roll(gray, 1, axis=1)
shifted_d = np.roll(gray, 1, axis=0)
glcm_contrast = float(np.mean((gray - shifted_r) ** 2 + (gray - shifted_d) ** 2))
return {
"mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
"ndvi": ndvi, "texture_std": texture_std, "lbp_variance": lbp_variance,
"edge_density": edge_density, "orientation_entropy": orientation_entropy,
"glcm_contrast": glcm_contrast,
"color_homogeneity": float(np.mean(std_rgb)),
"brightness": float(mean_lab[0]),
"green_ratio": green_ratio, "blue_ratio": blue_ratio, "red_ratio": red_ratio,
"saturation": float(mean_hsv[1]), "hue": float(mean_hsv[0]),
}
def _is_transient_object(area, w, h, features):
"""
Filter out transient objects (people, cars, animals, shadows, etc.)
that are NOT permanent ground/structural changes.
Returns True if the region is likely transient and should be excluded.
"""
aspect_ratio = max(w, h) / max(min(w, h), 1)
# Very small regions are likely noise, people, or small vehicles
if area < 300:
return True
# Tall narrow regions (aspect > 4) are likely people or poles
if aspect_ratio > 5.0 and area < 2000:
return True
# Very high edge density + small area = likely a person or vehicle
if features["edge_density"] > 80 and area < 1500:
return True
# Extremely high texture variance in small area = likely transient clutter
if features["texture_std"] > 60 and area < 1000:
return True
return False
def classify_object_type(image_region, bbox):
"""
Classify GROUND-LEVEL structural changes only.
Categories: construction, demolition, vegetation, water, road, bare land.
Transient objects (people, cars, animals) are filtered out.
"""
x, y, w, h = bbox
pad = 5
y1 = max(0, y - pad)
y2 = min(image_region.shape[0], y + h + pad)
x1 = max(0, x - pad)
x2 = min(image_region.shape[1], x + w + pad)
region = image_region[y1:y2, x1:x2]
if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3:
return "Unclassified", 0.0
features = extract_advanced_features(region)
if features is None:
return "Unclassified", 0.0
area = w * h
# Filter out transient objects (people, cars, animals)
if _is_transient_object(area, w, h, features):
return None, 0.0 # signal to exclude this region
aspect_ratio = max(w, h) / max(min(w, h), 1)
compactness = (4 * np.pi * area) / ((2 * (w + h)) ** 2 + 1e-6)
scores = {}
# ---- Water Body Change ----
water = 0.0
if features["blue_ratio"] > 0.36:
water += 0.22
if features["texture_std"] < 28:
water += 0.18
if features["edge_density"] < 35:
water += 0.14
if 90 <= features["hue"] <= 135:
water += 0.18
if features["lbp_variance"] < 0.05:
water += 0.14
if features["glcm_contrast"] < 500:
water += 0.10
if area > 800:
water += 0.04
scores["Water Body Change"] = water
# ---- Vegetation Change (deforestation, new growth, crop change) ----
veg = 0.0
if features["ndvi"] > 0.05:
veg += 0.22
if features["ndvi"] > 0.15:
veg += 0.10
if features["green_ratio"] > 0.36:
veg += 0.18
if 35 <= features["hue"] <= 85:
veg += 0.15
if features["texture_std"] > 18:
veg += 0.08
if features["lbp_variance"] > 0.03:
veg += 0.08
if features["saturation"] > 40:
veg += 0.10
if features["orientation_entropy"] > 2.5:
veg += 0.05
if area > 500:
veg += 0.04
scores["Vegetation Change"] = veg
# ---- New Construction/Building ----
bld = 0.0
if features["orientation_entropy"] < 2.5:
bld += 0.18
if features["color_homogeneity"] < 28:
bld += 0.15
if 1.0 <= aspect_ratio <= 4.0:
bld += 0.12
if 0.3 <= compactness <= 0.9:
bld += 0.10
if features["edge_density"] > 30:
bld += 0.12
if features["glcm_contrast"] > 400:
bld += 0.10
if features["saturation"] < 90:
bld += 0.10
if 40 <= features["brightness"] <= 90:
bld += 0.08
if area > 1000:
bld += 0.05
scores["New Construction/Building"] = bld
# ---- Demolition/Clearing ----
demo = 0.0
if features["texture_std"] > 30:
demo += 0.18
if features["orientation_entropy"] > 2.8:
demo += 0.15
if features["color_homogeneity"] > 25:
demo += 0.15
if features["brightness"] > 60:
demo += 0.10
if features["ndvi"] < 0.05:
demo += 0.12
if features["saturation"] < 70:
demo += 0.10
if area > 800:
demo += 0.05
scores["Demolition/Clearing"] = demo
# ---- Road/Pavement Change ----
road = 0.0
if aspect_ratio > 2.5:
road += 0.22
if features["color_homogeneity"] < 22:
road += 0.18
if features["texture_std"] < 32:
road += 0.15
if features["saturation"] < 65:
road += 0.12
if features["orientation_entropy"] < 2.0:
road += 0.15
if 35 <= features["brightness"] <= 75:
road += 0.10
if compactness < 0.3:
road += 0.05
if area > 600:
road += 0.03
scores["Road/Pavement Change"] = road
# ---- Bare Land/Soil Change ----
soil = 0.0
if features["red_ratio"] > 0.34 and features["green_ratio"] < 0.36:
soil += 0.20
if 8 <= features["hue"] <= 38:
soil += 0.18
if features["ndvi"] < 0.05:
soil += 0.18
if features["texture_std"] < 35:
soil += 0.12
if features["lbp_variance"] < 0.04:
soil += 0.12
if 40 <= features["saturation"] <= 130:
soil += 0.10
if 45 <= features["brightness"] <= 82:
soil += 0.10
scores["Bare Land/Soil Change"] = soil
# Use raw scores as confidence (each rule set sums to ~1.0 max)
# Do NOT normalize by max_score — that inflates weak matches to 1.0
best = max(scores, key=scores.get)
conf = scores[best]
if conf < 0.30:
return "Unclassified", conf
return best, min(conf, 1.0)
def classify_with_ensemble(image_region, bbox):
"""Ensemble: classify full region + sub-regions, vote with confidence weighting."""
x, y, w, h = bbox
sub_boxes = [(x, y, w, h)] # full region
if w > 20 and h > 20:
hw, hh = w // 2, h // 2
sub_boxes += [
(x, y, hw, hh),
(x + hw, y, hw, hh),
(x, y + hh, hw, hh),
(x + hw, y + hh, hw, hh),
(x + w // 4, y + h // 4, hw, hh),
]
classifications = []
confidences = []
transient_count = 0
for sb in sub_boxes:
obj_type, conf = classify_object_type(image_region, sb)
if obj_type is None:
transient_count += 1
continue
if obj_type != "Unclassified":
classifications.append(obj_type)
confidences.append(conf)
# Only exclude if majority of sub-regions are transient
if transient_count > len(sub_boxes) // 2:
return None, 0.0
if not classifications:
return classify_object_type(image_region, (x, y, w, h))
# Weighted voting
weighted = {}
counts = Counter(classifications)
for ot, c in zip(classifications, confidences):
weighted[ot] = weighted.get(ot, 0) + c
best_type = max(weighted, key=weighted.get)
avg_conf = weighted[best_type] / counts[best_type]
if counts[best_type] / len(classifications) >= 0.6:
avg_conf = min(1.0, avg_conf * 1.15)
return best_type, avg_conf
# ---------------------------------------------------------------------------
# 11. Vegetation sub-classification
# ---------------------------------------------------------------------------
_VEGETATION_TYPES = {"Vegetation Change"}
def _compute_region_greenness(crop):
"""Return (ndvi, green_ratio, mean_saturation) for an RGB crop."""
if crop.size == 0 or crop.shape[0] < 2 or crop.shape[1] < 2:
return 0.0, 0.0, 0.0
mean_rgb = np.mean(crop, axis=(0, 1)).astype(np.float64)
total = np.sum(mean_rgb) + 1e-6
green_ratio = mean_rgb[1] / total
ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
hsv = cv2.cvtColor(crop, cv2.COLOR_RGB2HSV)
mean_sat = float(np.mean(hsv[:, :, 1]))
return float(ndvi), float(green_ratio), mean_sat
def _compute_texture_regularity(gray_crop):
"""Measure how regular/grid-like the texture is (low entropy = regular crops)."""
if gray_crop.size == 0 or gray_crop.shape[0] < 3 or gray_crop.shape[1] < 3:
return 3.0
gx = cv2.Sobel(gray_crop.astype(np.float32), cv2.CV_64F, 1, 0, ksize=3)
gy = cv2.Sobel(gray_crop.astype(np.float32), cv2.CV_64F, 0, 1, ksize=3)
angles = np.arctan2(gy, gx + 1e-8)
hist, _ = np.histogram(angles, bins=8, range=(-np.pi, np.pi))
hist = hist / (hist.sum() + 1e-8)
entropy = -np.sum(hist[hist > 0] * np.log2(hist[hist > 0] + 1e-10))
return float(entropy)
def classify_vegetation_subtype(before_img, after_img, bbox):
"""
Compare before/after crops to determine vegetation change sub-type.
Returns (subtype_name, confidence).
"""
x, y, w, h = bbox
pad = 5
y1, y2 = max(0, y - pad), min(before_img.shape[0], y + h + pad)
x1, x2 = max(0, x - pad), min(before_img.shape[1], x + w + pad)
before_crop = before_img[y1:y2, x1:x2]
after_crop = after_img[y1:y2, x1:x2]
if before_crop.size == 0 or after_crop.size == 0:
return "Vegetation Change", 0.3
ndvi_b, green_b, sat_b = _compute_region_greenness(before_crop)
ndvi_a, green_a, sat_a = _compute_region_greenness(after_crop)
gray_b = cv2.cvtColor(before_crop, cv2.COLOR_RGB2GRAY)
gray_a = cv2.cvtColor(after_crop, cv2.COLOR_RGB2GRAY)
tex_entropy_b = _compute_texture_regularity(gray_b)
tex_entropy_a = _compute_texture_regularity(gray_a)
brightness_b = float(np.mean(gray_b))
brightness_a = float(np.mean(gray_a))
ndvi_delta = ndvi_a - ndvi_b
green_delta = green_a - green_b
sat_delta = sat_a - sat_b
scores = {
"Deforestation/Tree Removal": 0.0,
"New Vegetation/Growth": 0.0,
"Crop/Agricultural Change": 0.0,
"Vegetation Health Decline": 0.0,
"Seasonal Variation": 0.0,
}
# --- Deforestation: was green, now not green ---
if ndvi_b > 0.08 and ndvi_delta < -0.06:
scores["Deforestation/Tree Removal"] += 0.30
if green_b > 0.36 and green_delta < -0.03:
scores["Deforestation/Tree Removal"] += 0.20
if brightness_a > brightness_b + 10:
scores["Deforestation/Tree Removal"] += 0.15
if sat_delta < -15:
scores["Deforestation/Tree Removal"] += 0.15
if tex_entropy_a < tex_entropy_b - 0.3:
scores["Deforestation/Tree Removal"] += 0.10
# --- New Vegetation/Growth: was bare, now green ---
if ndvi_a > 0.08 and ndvi_delta > 0.06:
scores["New Vegetation/Growth"] += 0.30
if green_a > 0.36 and green_delta > 0.03:
scores["New Vegetation/Growth"] += 0.20
if sat_delta > 15:
scores["New Vegetation/Growth"] += 0.15
if brightness_a < brightness_b - 5:
scores["New Vegetation/Growth"] += 0.10
if tex_entropy_a > tex_entropy_b + 0.2:
scores["New Vegetation/Growth"] += 0.10
# --- Crop/Agricultural Change: regular texture patterns, moderate color shift ---
is_regular = tex_entropy_b < 2.5 or tex_entropy_a < 2.5
if is_regular:
scores["Crop/Agricultural Change"] += 0.25
if 0.03 < abs(ndvi_delta) < 0.12:
scores["Crop/Agricultural Change"] += 0.20
if sat_b > 35 and sat_a > 35:
scores["Crop/Agricultural Change"] += 0.15
if abs(green_delta) < 0.04 and abs(ndvi_delta) > 0.02:
scores["Crop/Agricultural Change"] += 0.15
area = w * h
if area > 3000:
scores["Crop/Agricultural Change"] += 0.10
# --- Vegetation Health Decline: still green but browning ---
if ndvi_b > 0.05 and ndvi_a > 0.02 and ndvi_delta < -0.03:
scores["Vegetation Health Decline"] += 0.25
if green_b > 0.34 and green_a > 0.30 and green_delta < -0.02:
scores["Vegetation Health Decline"] += 0.20
if -20 < sat_delta < -3:
scores["Vegetation Health Decline"] += 0.20
if abs(brightness_a - brightness_b) < 15:
scores["Vegetation Health Decline"] += 0.10
# --- Seasonal Variation: mild shift in color/texture, both sides green ---
if ndvi_b > 0.04 and ndvi_a > 0.04 and abs(ndvi_delta) < 0.05:
scores["Seasonal Variation"] += 0.25
if abs(green_delta) < 0.03:
scores["Seasonal Variation"] += 0.20
if abs(sat_delta) < 12:
scores["Seasonal Variation"] += 0.15
if abs(brightness_a - brightness_b) < 12:
scores["Seasonal Variation"] += 0.15
best = max(scores, key=scores.get)
conf = scores[best]
if conf < 0.25:
return "Vegetation Change", 0.3
return best, min(conf, 1.0)
# ---------------------------------------------------------------------------
# 12. Structural change sub-classification
# ---------------------------------------------------------------------------
_STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
"Road/Pavement Change"}
def _region_has_structure(crop):
"""Heuristic: does this crop contain building-like structure (edges + regularity)?"""
if crop.size == 0 or crop.shape[0] < 3 or crop.shape[1] < 3:
return False, 0.0, 0.0
gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY).astype(np.float32)
gx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
edge_density = float(np.mean(np.sqrt(gx**2 + gy**2)))
angles = np.arctan2(gy, gx + 1e-8)
hist, _ = np.histogram(angles, bins=8, range=(-np.pi, np.pi))
hist = hist / (hist.sum() + 1e-8)
entropy = -np.sum(hist[hist > 0] * np.log2(hist[hist > 0] + 1e-10))
has_structure = edge_density > 25 and entropy < 2.8
return has_structure, edge_density, entropy
def classify_structural_subtype(before_img, after_img, bbox, main_type):
"""
Compare before/after crops to determine structural change sub-type.
Returns (subtype_name, confidence).
"""
x, y, w, h = bbox
pad = 5
y1, y2 = max(0, y - pad), min(before_img.shape[0], y + h + pad)
x1, x2 = max(0, x - pad), min(before_img.shape[1], x + w + pad)
before_crop = before_img[y1:y2, x1:x2]
after_crop = after_img[y1:y2, x1:x2]
if before_crop.size == 0 or after_crop.size == 0:
return main_type, 0.3
struct_b, edge_b, ent_b = _region_has_structure(before_crop)
struct_a, edge_a, ent_a = _region_has_structure(after_crop)
gray_b = cv2.cvtColor(before_crop, cv2.COLOR_RGB2GRAY)
gray_a = cv2.cvtColor(after_crop, cv2.COLOR_RGB2GRAY)
brightness_b = float(np.mean(gray_b))
brightness_a = float(np.mean(gray_a))
texture_b = float(np.std(gray_b))
texture_a = float(np.std(gray_a))
hsv_b = cv2.cvtColor(before_crop, cv2.COLOR_RGB2HSV)
hsv_a = cv2.cvtColor(after_crop, cv2.COLOR_RGB2HSV)
sat_b = float(np.mean(hsv_b[:, :, 1]))
sat_a = float(np.mean(hsv_a[:, :, 1]))
# Check greenness to detect cleared-to-green or green-to-built transitions
mean_rgb_b = np.mean(before_crop, axis=(0, 1))
mean_rgb_a = np.mean(after_crop, axis=(0, 1))
ndvi_b = (mean_rgb_b[1] - mean_rgb_b[0]) / (mean_rgb_b[1] + mean_rgb_b[0] + 1e-6)
ndvi_a = (mean_rgb_a[1] - mean_rgb_a[0]) / (mean_rgb_a[1] + mean_rgb_a[0] + 1e-6)
area = w * h
if main_type == "Road/Pavement Change":
return _classify_road_subtype(
struct_b, struct_a, edge_b, edge_a, brightness_b, brightness_a,
texture_b, texture_a, area, w, h
)
scores = {
"New Building": 0.0,
"Building Expansion": 0.0,
"Renovation/Modification": 0.0,
"Partial Demolition": 0.0,
"Full Demolition": 0.0,
"Infrastructure Change": 0.0,
}
# --- New Building: before had no structure, after does ---
if not struct_b and struct_a:
scores["New Building"] += 0.35
if edge_a > edge_b + 15:
scores["New Building"] += 0.15
if ent_a < ent_b - 0.3:
scores["New Building"] += 0.10
if ndvi_b > 0.05 and ndvi_a < 0.03:
scores["New Building"] += 0.10
if sat_a < sat_b - 10:
scores["New Building"] += 0.10
# --- Building Expansion: both have structure but after has more ---
if struct_b and struct_a:
scores["Building Expansion"] += 0.15
if struct_b and edge_a > edge_b * 1.2:
scores["Building Expansion"] += 0.20
if struct_b and texture_a > texture_b + 5:
scores["Building Expansion"] += 0.15
if abs(ent_a - ent_b) < 0.5 and edge_a > edge_b:
scores["Building Expansion"] += 0.15
# --- Renovation/Modification: both have structure, similar density but different appearance ---
if struct_b and struct_a:
scores["Renovation/Modification"] += 0.15
if abs(edge_a - edge_b) < 12:
scores["Renovation/Modification"] += 0.15
if abs(brightness_a - brightness_b) > 8:
scores["Renovation/Modification"] += 0.20
if abs(sat_a - sat_b) > 10:
scores["Renovation/Modification"] += 0.15
if abs(texture_a - texture_b) < 10:
scores["Renovation/Modification"] += 0.10
# --- Partial Demolition: before had structure, after has less ---
if struct_b and edge_a < edge_b * 0.7:
scores["Partial Demolition"] += 0.25
if struct_b and ent_a > ent_b + 0.3:
scores["Partial Demolition"] += 0.15
if texture_a > texture_b + 8:
scores["Partial Demolition"] += 0.15
if brightness_a > brightness_b + 10:
scores["Partial Demolition"] += 0.10
# --- Full Demolition: before had structure, after is bare/empty ---
if struct_b and not struct_a:
scores["Full Demolition"] += 0.35
if edge_b > 30 and edge_a < 20:
scores["Full Demolition"] += 0.15
if texture_b > 25 and texture_a < 20:
scores["Full Demolition"] += 0.15
if brightness_a > brightness_b + 15:
scores["Full Demolition"] += 0.10
# --- Infrastructure Change: elongated shape, high edge regularity ---
aspect = max(w, h) / max(min(w, h), 1)
if aspect > 3.0:
scores["Infrastructure Change"] += 0.25
if ent_a < 2.0 or ent_b < 2.0:
scores["Infrastructure Change"] += 0.15
if area > 2000 and aspect > 2.5:
scores["Infrastructure Change"] += 0.15
best = max(scores, key=scores.get)
conf = scores[best]
if conf < 0.25:
return main_type, 0.3
return best, min(conf, 1.0)
def _classify_road_subtype(struct_b, struct_a, edge_b, edge_a,
brightness_b, brightness_a, texture_b, texture_a,
area, w, h):
"""Sub-classify road/pavement changes."""
scores = {
"New Road/Pavement": 0.0,
"Road Widening": 0.0,
"Road Resurfacing": 0.0,
"Road Deterioration": 0.0,
}
if not struct_b and struct_a:
scores["New Road/Pavement"] += 0.30
if edge_a > edge_b + 10:
scores["New Road/Pavement"] += 0.20
if brightness_a < brightness_b:
scores["New Road/Pavement"] += 0.15
if struct_b and struct_a and edge_a > edge_b * 1.15:
scores["Road Widening"] += 0.30
if area > 2000:
scores["Road Widening"] += 0.15
if struct_b and struct_a and abs(edge_a - edge_b) < 10:
scores["Road Resurfacing"] += 0.20
if abs(brightness_a - brightness_b) > 12:
scores["Road Resurfacing"] += 0.25
if abs(texture_a - texture_b) < 8:
scores["Road Resurfacing"] += 0.15
if texture_a > texture_b + 10:
scores["Road Deterioration"] += 0.25
if edge_a < edge_b - 5:
scores["Road Deterioration"] += 0.20
if brightness_a > brightness_b + 8:
scores["Road Deterioration"] += 0.15
best = max(scores, key=scores.get)
conf = scores[best]
if conf < 0.25:
return "Road/Pavement Change", 0.3
return best, min(conf, 1.0)
# ---------------------------------------------------------------------------
# 13. 3D Building Analysis — height estimation + construction stage
# ---------------------------------------------------------------------------
_BUILDING_TYPES = {"New Construction/Building", "Demolition/Clearing"}
_STORY_HEIGHT_M = 3.0 # assumed metres per story
def _detect_shadow_region(before_gray, after_gray, bbox, expand=0.6):
"""
Find new shadow pixels adjacent to a building bbox.
Returns a binary mask of likely shadow pixels in the expanded bbox area.
"""
x, y, w, h = bbox
img_h, img_w = after_gray.shape[:2]
# Expand bbox to capture shadows cast beside the building
ex = int(w * expand)
ey = int(h * expand)
x1 = max(0, x - ex)
y1 = max(0, y - ey)
x2 = min(img_w, x + w + ex)
y2 = min(img_h, y + h + ey)
before_crop = before_gray[y1:y2, x1:x2].astype(np.float32)
after_crop = after_gray[y1:y2, x1:x2].astype(np.float32)
if before_crop.size == 0 or after_crop.size == 0:
return None, 0
# New shadow = pixels that got significantly darker in the after image
darkening = before_crop - after_crop
dark_thresh = max(25, np.std(darkening) * 1.5)
shadow_mask = (darkening > dark_thresh).astype(np.uint8) * 255
# Remove shadow pixels inside the building footprint itself
bx1, by1 = x - x1, y - y1
bx2, by2 = bx1 + w, by1 + h
bx1, by1 = max(0, bx1), max(0, by1)
bx2 = min(shadow_mask.shape[1], bx2)
by2 = min(shadow_mask.shape[0], by2)
shadow_mask[by1:by2, bx1:bx2] = 0
# Clean noise
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
shadow_mask = cv2.morphologyEx(shadow_mask, cv2.MORPH_OPEN, kernel)
shadow_pixels = np.sum(shadow_mask > 0)
return shadow_mask, shadow_pixels
def estimate_building_height(before_img, after_img, bbox, features):
"""
Estimate building stories and height from shadow length and footprint geometry.
Returns (estimated_stories, estimated_height_m).
"""
before_gray = cv2.cvtColor(before_img, cv2.COLOR_RGB2GRAY)
after_gray = cv2.cvtColor(after_img, cv2.COLOR_RGB2GRAY)
x, y, w, h = bbox
shadow_mask, shadow_px = _detect_shadow_region(before_gray, after_gray, bbox)
short_side = max(min(w, h), 1)
footprint_area = w * h
# --- Shadow-based estimate ---
shadow_ratio = 0.0
if shadow_mask is not None and shadow_px > 20:
# Measure max extent of shadow perpendicular to building edge
coords = np.column_stack(np.where(shadow_mask > 0))
if len(coords) > 5:
# Shadow length = extent along the longer axis of shadow cluster
spread_y = coords[:, 0].max() - coords[:, 0].min()
spread_x = coords[:, 1].max() - coords[:, 1].min()
shadow_length = max(spread_y, spread_x)
shadow_ratio = shadow_length / short_side
# --- Footprint-based estimate ---
aspect = max(w, h) / max(short_side, 1)
# Compact footprints (aspect < 2.5) tend to be multi-story; elongated are single-story
footprint_factor = 1.0
if aspect > 3.0:
footprint_factor = 0.5 # likely single-story warehouse/industrial
elif aspect < 1.5 and footprint_area > 2000:
footprint_factor = 1.3 # compact large footprint = likely taller
# --- Texture regularity bonus ---
# Buildings with low orientation entropy (regular structure) tend to be taller
regularity_bonus = 0.0
if features and features.get("orientation_entropy", 3.0) < 2.2:
regularity_bonus = 0.5
# --- Combine signals ---
# Base: shadow ratio maps ~0.3-0.5 per story in typical nadir imagery
if shadow_ratio > 0.1:
raw_stories = shadow_ratio / 0.35
else:
# No clear shadow: use footprint area as rough proxy
if footprint_area > 5000:
raw_stories = 3.0
elif footprint_area > 2000:
raw_stories = 2.0
else:
raw_stories = 1.0
raw_stories = raw_stories * footprint_factor + regularity_bonus
stories = max(1, min(50, int(round(raw_stories))))
height_m = round(stories * _STORY_HEIGHT_M, 1)
return stories, height_m
def classify_construction_stage(features, bbox):
"""
Classify construction stage from visual features.
Returns (stage_name, confidence).
"""
if features is None:
return "Unknown", 0.0
w, h = bbox[2], bbox[3]
area = w * h
scores = {
"Foundation": 0.0,
"Structural": 0.0,
"Under Construction": 0.0,
"Complete": 0.0,
}
tex = features.get("texture_std", 30)
edge = features.get("edge_density", 40)
orient = features.get("orientation_entropy", 2.5)
homog = features.get("color_homogeneity", 25)
bright = features.get("brightness", 60)
sat = features.get("saturation", 50)
glcm = features.get("glcm_contrast", 500)
lbp_var = features.get("lbp_variance", 0.04)
# --- Foundation ---
# Flat, low-texture, soil/concrete colored, homogeneous
if tex < 22:
scores["Foundation"] += 0.25
if edge < 30:
scores["Foundation"] += 0.20
if homog < 20:
scores["Foundation"] += 0.20
if 40 <= bright <= 75:
scores["Foundation"] += 0.15
if sat < 60:
scores["Foundation"] += 0.10
if lbp_var < 0.03:
scores["Foundation"] += 0.10
# --- Structural/Framing ---
# High edges, geometric regularity, high contrast grid patterns
if edge > 50:
scores["Structural"] += 0.25
if orient < 2.2:
scores["Structural"] += 0.20
if glcm > 800:
scores["Structural"] += 0.20
if tex > 30:
scores["Structural"] += 0.15
if homog > 30:
scores["Structural"] += 0.10
if area > 1000:
scores["Structural"] += 0.10
# --- Under Construction ---
# Mixed materials, irregular texture, medium-high edge density
if 25 < tex < 50:
scores["Under Construction"] += 0.20
if 35 < edge < 65:
scores["Under Construction"] += 0.20
if orient > 2.6:
scores["Under Construction"] += 0.20
if homog > 25:
scores["Under Construction"] += 0.15
if 0.03 < lbp_var < 0.07:
scores["Under Construction"] += 0.15
if sat < 80:
scores["Under Construction"] += 0.10
# --- Complete ---
# Uniform roof, clean edges, low entropy, consistent color
if tex < 28:
scores["Complete"] += 0.20
if orient < 2.3:
scores["Complete"] += 0.25
if homog < 22:
scores["Complete"] += 0.20
if edge > 25:
scores["Complete"] += 0.10
if lbp_var < 0.04:
scores["Complete"] += 0.15
if bright > 50:
scores["Complete"] += 0.10
best = max(scores, key=scores.get)
conf = scores[best]
if conf < 0.25:
return "Unknown", conf
return best, min(conf, 1.0)
def analyze_building_3d(before_img, after_img, region, features):
"""
Run 3D analysis on a single building/construction region.
Enriches the region dict with stories, height, and construction stage.
"""
bbox = region["bbox"]
stories, height_m = estimate_building_height(before_img, after_img, bbox, features)
stage, stage_conf = classify_construction_stage(features, bbox)
region["estimated_stories"] = stories
region["estimated_height_m"] = height_m
region["construction_stage"] = stage
region["construction_stage_confidence"] = stage_conf
return region
# ---------------------------------------------------------------------------
# 14. Region analysis
# ---------------------------------------------------------------------------
def _tight_bbox(labels, label_id, stats_row):
"""
Compute a tighter bounding box using actual changed pixels.
Falls back to the connected-component bbox if the mask is dense enough.
"""
x = stats_row[cv2.CC_STAT_LEFT]
y = stats_row[cv2.CC_STAT_TOP]
w = stats_row[cv2.CC_STAT_WIDTH]
h = stats_row[cv2.CC_STAT_HEIGHT]
area = stats_row[cv2.CC_STAT_AREA]
fill_ratio = area / max(w * h, 1)
# If the component fills less than 20% of its bbox, compute a tighter fit
if fill_ratio < 0.20 and area > 100:
ys, xs = np.where(labels == label_id)
if len(xs) > 0:
x = int(np.min(xs))
y = int(np.min(ys))
w = int(np.max(xs) - x + 1)
h = int(np.max(ys) - y + 1)
fill_ratio = area / max(w * h, 1)
return x, y, w, h, fill_ratio
def _iou(boxA, boxB):
"""Intersection-over-union for two (x,y,w,h) boxes."""
ax1, ay1, aw, ah = boxA
bx1, by1, bw, bh = boxB
ax2, ay2 = ax1 + aw, ay1 + ah
bx2, by2 = bx1 + bw, by1 + bh
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
union = aw * ah + bw * bh - inter
return inter / max(union, 1)
def _nms_regions(regions, iou_thresh=0.45):
"""Non-maximum suppression: keep the highest-area box when two overlap."""
if len(regions) < 2:
return regions
keep = []
used = set()
for i, r in enumerate(regions):
if i in used:
continue
keep.append(r)
for j in range(i + 1, len(regions)):
if j in used:
continue
if _iou(r["bbox"], regions[j]["bbox"]) > iou_thresh:
used.add(j)
return keep
def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
before_img=None):
"""
Find connected change regions with strict quality filters:
- Higher min_area (400) to reject noise
- Fill-ratio filter: reject boxes that are mostly empty
- Tighter bounding boxes computed from actual pixel coordinates
- NMS to remove overlapping/duplicate boxes
- Max 60 regions cap to avoid flooding the UI
"""
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
change_mask, connectivity=8)
change_regions = []
region_id = 0
img_h, img_w = change_mask.shape[:2]
img_area = img_h * img_w
for i in range(1, num_labels):
raw_area = stats[i, cv2.CC_STAT_AREA]
if raw_area < min_area:
continue
x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
# Reject very sparse regions (bbox is mostly empty)
if fill_ratio < 0.10:
continue
# Reject regions that cover more than 40% of the image (likely a global
# illumination shift, not a real change)
if (w * h) > img_area * 0.40:
continue
cx, cy = centroids[i]
if use_ensemble and raw_area > 500:
object_type, confidence = classify_with_ensemble(image, (x, y, w, h))
else:
object_type, confidence = classify_object_type(image, (x, y, w, h))
if object_type is None:
continue
region_id += 1
region = {
"id": region_id,
"area": int(raw_area),
"bbox": (x, y, w, h),
"center": (int(cx), int(cy)),
"object_type": object_type,
"confidence": confidence,
"fill_ratio": round(fill_ratio, 3),
"sub_type": None,
"sub_type_confidence": None,
"estimated_stories": None,
"estimated_height_m": None,
"construction_stage": None,
}
if before_img is not None:
if object_type in _VEGETATION_TYPES:
sub, sub_conf = classify_vegetation_subtype(
before_img, image, (x, y, w, h))
region["sub_type"] = sub
region["sub_type_confidence"] = sub_conf
elif object_type in _STRUCTURAL_TYPES:
sub, sub_conf = classify_structural_subtype(
before_img, image, (x, y, w, h), object_type)
region["sub_type"] = sub
region["sub_type_confidence"] = sub_conf
if object_type in _BUILDING_TYPES:
pad = 5
ry1 = max(0, y - pad)
ry2 = min(image.shape[0], y + h + pad)
rx1 = max(0, x - pad)
rx2 = min(image.shape[1], x + w + pad)
crop = image[ry1:ry2, rx1:rx2]
feats = extract_advanced_features(crop) if crop.size > 0 else None
analyze_building_3d(before_img, image, region, feats)
change_regions.append(region)
# Sort by area descending, apply NMS, cap at 60
change_regions.sort(key=lambda r: r["area"], reverse=True)
change_regions = _nms_regions(change_regions, iou_thresh=0.45)
change_regions = change_regions[:60]
# Re-number after filtering
for idx, r in enumerate(change_regions, start=1):
r["id"] = idx
total_px = img_area
for r in change_regions:
r["severity"] = _severity_from_region(r, total_px)
return change_regions
# ---------------------------------------------------------------------------
# 15. Main pipeline
# ---------------------------------------------------------------------------
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
enable_registration=True, enable_normalization=True):
"""Run full detection pipeline; returns change_mask, result_image, stats, regions."""
before_array = preprocess_image(before_pil)
after_array = preprocess_image(after_pil)
if enable_registration:
before_array, after_array, _ = register_images(before_array, after_array)
if enable_normalization:
before_array, after_array = normalize_radiometry(before_array, after_array)
if method == "AI-Based Deep Learning":
change_mask = ai_deep_learning_method(before_array, after_array)
elif method == "Image Difference":
change_mask = image_difference_method(before_array, after_array)
elif method == "Feature-Based":
change_mask = feature_based_method(before_array, after_array)
else:
change_mask = hybrid_method(before_array, after_array)
change_regions = analyze_change_regions(
change_mask, after_array, min_area=400, before_img=before_array
)
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
result_image = visualize_changes(
before_array, after_array, change_mask,
regions=change_regions, total_pixels=total_pixels
)
changed_pixels = int(np.sum(change_mask > 127))
change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
stats = {
"total_pixels": total_pixels,
"changed_pixels": changed_pixels,
"unchanged_pixels": total_pixels - changed_pixels,
"change_percentage": change_pct,
"image_width": change_mask.shape[1],
"image_height": change_mask.shape[0],
}
return change_mask, result_image, stats, change_regions