Spaces:
Running on Zero
Running on Zero
Download shadow_detection.py from eira06/depthwizard-backend: direct link, hf CLI and curl.
- Browser
- Download file 9.05 kB
-
https://huggingface.co/spaces/eira06/depthwizard-backend/resolve/main/shadow_detection.py
- Command line
-
hf download hf://spaces/eira06/depthwizard-backend/shadow_detection.py
-
curl -L -o shadow_detection.py https://huggingface.co/spaces/eira06/depthwizard-backend/resolve/main/shadow_detection.py
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 | |