depthwizard-backend / shadow_detection.py
Priya Soni
Deploy DepthWizard backend
ad38b1a
Raw History Blame Contribute Delete
9.05 kB
"""
Module: shadow_detection
Description: Shadow detection, morphology filtering, and shadow-geometry height constraint module.
Supports:
- MODE A: Solar metadata available (L * tan(solar_elevation))
- MODE B: Solar metadata unavailable (structural/edge confidence only, no fabricated heights)
"""
import numpy as np
from typing import Tuple, Dict, Any, Optional, List
from scipy.ndimage import (
binary_opening,
binary_closing,
label,
find_objects,
generate_binary_structure
)
def compute_shadow_confidence(
rgb: np.ndarray,
luminance_percentile: float = 25.0,
blue_ratio_threshold: float = 0.95
) -> np.ndarray:
"""
Compute continuous shadow confidence map S(x,y) in [0, 1].
Shadows in high-resolution aerial imagery exhibit:
1. Low total luminance / value (V in HSV, I in RGB)
2. Higher relative blue/short-wavelength ratio due to Rayleigh scattered skylight
3. Low color saturation/variance compared to vegetation and roofs
Parameters:
- rgb: uint8 or float32 image (H, W, 3)
- luminance_percentile: Percentile threshold for low luminance candidate pixels
- blue_ratio_threshold: Minimum blue ratio for skylight illumination
Returns:
- shadow_conf: float32 array in [0, 1]
"""
if rgb.dtype == np.uint8:
rgb_f = rgb.astype(np.float32) / 255.0
else:
rgb_f = rgb.astype(np.float32)
if np.max(rgb_f) > 1.0:
rgb_f = rgb_f / 255.0
r = rgb_f[:, :, 0]
g = rgb_f[:, :, 1]
b = rgb_f[:, :, 2]
# Luminance / Intensity
intensity = 0.299 * r + 0.587 * g + 0.114 * b
# Blue ratio relative to red/green
rg_mean = (r + g) / 2.0 + 1e-5
blue_ratio = b / rg_mean
# Compute luminance threshold from the image distribution
lum_thresh = np.percentile(intensity, luminance_percentile)
# Continuous confidence formulation:
# 1. Dark score: higher for darker pixels below threshold
dark_score = np.clip((lum_thresh * 1.5 - intensity) / (lum_thresh * 1.5 + 1e-5), 0.0, 1.0)
# 2. Spectral skylight score: boosted if blue ratio >= 1.0 (ambient scattered skylight)
blue_score = np.clip((blue_ratio - 0.8) / 0.5, 0.0, 1.0)
# 3. Ratio-based shadow index (Tsai's / C3 inspired):
diff_br = (b - r) / (b + r + 1e-4)
spectral_score = np.clip((diff_br + 0.1) / 0.4, 0.0, 1.0)
# Combine scores into continuous confidence
shadow_conf = dark_score * (0.6 * blue_score + 0.4 * spectral_score)
shadow_conf = np.clip(shadow_conf, 0.0, 1.0).astype(np.float32)
return shadow_conf
def filter_shadow_mask(
shadow_conf: np.ndarray,
confidence_threshold: float = 0.35,
min_area_pixels: int = 50,
closing_radius: int = 3,
opening_radius: int = 2
) -> np.ndarray:
"""
Apply morphological opening and closing and minimum connected-component
area filtering to produce a clean binary shadow mask.
"""
raw_binary = shadow_conf >= confidence_threshold
struct = generate_binary_structure(2, 2)
# Morphological closing to fill small holes inside shadows
if closing_radius > 0:
closed = binary_closing(raw_binary, structure=struct, iterations=closing_radius)
else:
closed = raw_binary
# Morphological opening to eliminate isolated noisy speckles
if opening_radius > 0:
opened = binary_opening(closed, structure=struct, iterations=opening_radius)
else:
opened = closed
# Connected component area filtering
labeled_array, num_features = label(opened, structure=struct)
if num_features == 0:
return np.zeros_like(shadow_conf, dtype=bool)
# Fast bincount of component sizes
sizes = np.bincount(labeled_array.ravel())
mask_sizes = sizes >= min_area_pixels
mask_sizes[0] = False # Background
clean_mask = mask_sizes[labeled_array]
return clean_mask
def detect_building_shadow_pairs(
rgb: np.ndarray,
shadow_mask: np.ndarray,
initial_dsm: np.ndarray,
gsd_m: float = 0.05,
min_shadow_len_px: int = 10,
max_shadow_len_px: int = 500
) -> Dict[str, Any]:
"""
Identify candidate building structures adjacent to cast shadow regions
and estimate shadow length and casting direction.
"""
struct = generate_binary_structure(2, 2)
labeled_shadows, num_shadows = label(shadow_mask, structure=struct)
slices = find_objects(labeled_shadows)
building_shadow_pairs = []
shadow_lengths_m = []
shadow_directions_deg = []
# Building threshold: elevated pixels in DSM relative to local baseline
valid_dsm = initial_dsm[np.isfinite(initial_dsm)]
ground_datum = float(np.percentile(valid_dsm, 15.0)) if len(valid_dsm) > 0 else 40.0
building_mask = initial_dsm > (ground_datum + 2.5) # Buildings > 2.5m above ground
for idx, slc in enumerate(slices):
if slc is None:
continue
comp_mask = (labeled_shadows[slc] == (idx + 1))
area = int(np.sum(comp_mask))
if area < 50:
continue
r_slice, c_slice = slc
sub_dsm = initial_dsm[slc]
sub_bldg = building_mask[slc]
r_coords, c_coords = np.where(comp_mask)
if len(r_coords) < 10:
continue
coords = np.column_stack([r_coords, c_coords])
coords_centered = coords - np.mean(coords, axis=0)
cov = np.cov(coords_centered, rowvar=False)
if cov.ndim == 2:
eigvals, eigvecs = np.linalg.eigh(cov)
length_px = float(4.0 * np.sqrt(max(1.0, eigvals[1])))
length_m = length_px * gsd_m
major_vec = eigvecs[:, 1]
angle_deg = float(np.degrees(np.arctan2(major_vec[0], major_vec[1])))
else:
length_px = float(np.max(r_coords) - np.min(r_coords) + 1)
length_m = length_px * gsd_m
angle_deg = 0.0
if min_shadow_len_px <= length_px <= max_shadow_len_px:
shadow_lengths_m.append(length_m)
shadow_directions_deg.append(angle_deg)
building_shadow_pairs.append({
"shadow_id": idx + 1,
"bbox": [int(r_slice.start), int(r_slice.stop), int(c_slice.start), int(c_slice.stop)],
"area_px": area,
"length_px": length_px,
"length_m": length_m,
"direction_deg": angle_deg,
"mean_initial_dsm_m": float(np.mean(sub_dsm[comp_mask]))
})
return {
"num_shadow_candidates": num_shadows,
"num_accepted_pairs": len(building_shadow_pairs),
"pairs": building_shadow_pairs,
"shadow_lengths_m": shadow_lengths_m,
"shadow_directions_deg": shadow_directions_deg,
"mean_shadow_length_m": float(np.mean(shadow_lengths_m)) if shadow_lengths_m else 0.0,
"median_shadow_length_m": float(np.median(shadow_lengths_m)) if shadow_lengths_m else 0.0
}
def apply_shadow_height_constraint(
initial_dsm: np.ndarray,
shadow_mask: np.ndarray,
building_shadow_data: Dict[str, Any],
solar_elevation_deg: Optional[float] = None,
solar_azimuth_deg: Optional[float] = None
) -> Tuple[np.ndarray, Dict[str, Any]]:
"""
Apply shadow height constraint to refine DSM.
- MODE A: If solar_elevation_deg is provided, calculate H_shadow = L * tan(elevation)
and refine building height.
- MODE B: If solar_elevation_deg is None, DO NOT fabricate solar angles.
Use shadow regions to preserve ground datum and sharpen building step-edges.
"""
constrained_dsm = initial_dsm.copy()
if solar_elevation_deg is not None:
# MODE A: Solar geometry available
mode = "MODE_A_SOLAR_METADATA_AVAILABLE"
elev_rad = np.radians(solar_elevation_deg)
tan_elev = np.tan(elev_rad)
height_constraints = []
for pair in building_shadow_data.get("pairs", []):
l_m = pair["length_m"]
h_est = float(l_m * tan_elev)
pair["estimated_height_m"] = h_est
height_constraints.append(h_est)
summary = {
"mode": mode,
"solar_elevation_deg": solar_elevation_deg,
"solar_azimuth_deg": solar_azimuth_deg,
"num_height_constraints": len(height_constraints),
"mean_shadow_height_m": float(np.mean(height_constraints)) if height_constraints else 0.0,
"metric_height_applied": True
}
else:
# MODE B: Solar geometry unavailable
mode = "MODE_B_SOLAR_METADATA_UNAVAILABLE"
summary = {
"mode": mode,
"solar_elevation_deg": None,
"solar_azimuth_deg": None,
"num_height_constraints": 0,
"mean_shadow_height_m": None,
"metric_height_applied": False,
"reason": "Potsdam GeoTIFF metadata does not contain solar elevation/azimuth tags. "
"In accordance with scientific constraints, solar geometry is not fabricated."
}
return constrained_dsm, summary