File size: 8,415 Bytes
a358495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
165
166
167
168
from __future__ import annotations

from dataclasses import dataclass
from enum import IntEnum
from typing import Any

import numpy as np
from scipy import ndimage as ndi


class ShadowType(IntEnum):
    NONE = 0
    BUILDING_SHADOW = 1
    TERRAIN_SHADOW = 2
    CLOUD_SHADOW = 3
    VEGETATION_SHADOW = 4
    UNKNOWN_SHADOW = 5


@dataclass(frozen=True)
class ShadowEvidence:
    probability: np.ndarray
    mask: np.ndarray
    shadow_type: np.ndarray
    features: dict[str, np.ndarray]
    details: dict[str, Any]


def rgb_shadow_evidence(
    r: np.ndarray,
    g: np.ndarray,
    b: np.ndarray,
    valid: np.ndarray,
    *,
    water_probability: np.ndarray | None = None,
    building_mask: np.ndarray | None = None,
    vegetation_probability: np.ndarray | None = None,
) -> ShadowEvidence:
    """Estimate illumination shadow independently from surface identity.

    Darkness contributes evidence, but cannot by itself create a shadow mask.
    Local illumination contrast, chromatic continuation, texture/edges, and
    object adjacency provide the additional evidence.
    """
    r, g, b = (np.asarray(channel, dtype="float32") for channel in (r, g, b))
    valid = np.asarray(valid, dtype=bool)
    luminance = 0.299 * r + 0.587 * g + 0.114 * b
    local_mean = ndi.uniform_filter(np.where(valid, luminance, 0.0), size=21)
    valid_density = ndi.uniform_filter(valid.astype("float32"), size=21)
    local_mean = local_mean / np.maximum(valid_density, 1e-4)
    illumination_drop = np.clip((local_mean - luminance) / np.maximum(local_mean, 0.04), 0.0, 1.0)

    maximum = np.maximum.reduce([r, g, b])
    minimum = np.minimum.reduce([r, g, b])
    saturation = (maximum - minimum) / np.maximum(maximum, 1e-5)
    chromatic_neutrality = 1.0 - np.clip(saturation / 0.35, 0.0, 1.0)
    darkness = np.clip((0.34 - luminance) / 0.34, 0.0, 1.0)
    gradient = np.hypot(ndi.sobel(luminance, axis=0), ndi.sobel(luminance, axis=1)) / 4.0
    edge_support = np.clip(ndi.uniform_filter((gradient > 0.035).astype("float32"), 9) / 0.18, 0.0, 1.0)

    building_adj = np.zeros_like(luminance, dtype="float32")
    if building_mask is not None:
        objects = np.asarray(building_mask, dtype=bool)
        if objects.shape == valid.shape:
            building_adj = (ndi.binary_dilation(objects, iterations=8) & ~objects).astype("float32")
    vegetation_adj = np.zeros_like(luminance, dtype="float32")
    if vegetation_probability is not None:
        vegetation = np.asarray(vegetation_probability, dtype="float32") > 0.55
        if vegetation.shape == valid.shape:
            vegetation_adj = (ndi.binary_dilation(vegetation, iterations=5) & ~vegetation).astype("float32")
    bright_object = (luminance > 0.82) & valid
    bright_adjacency = (ndi.binary_dilation(bright_object, iterations=18) & ~bright_object).astype("float32")

    water_support = np.zeros_like(valid)
    within_water_drop = np.zeros_like(luminance, dtype="float32")
    if water_probability is not None:
        water_support = np.asarray(water_probability, dtype="float32") >= 0.55
        water_density = ndi.uniform_filter(water_support.astype("float32"), size=31)
        water_local = ndi.uniform_filter(np.where(water_support, luminance, 0.0), size=31) / np.maximum(water_density, 1e-4)
        within_water_drop = np.clip((water_local - luminance) / np.maximum(water_local, 0.02), 0.0, 1.0).astype("float32")

    # At least two independent families are required: darkness/local contrast
    # plus geometry/chromatic evidence. A dark pixel alone therefore stays low.
    probability = (
        0.25 * darkness
        + 0.32 * illumination_drop
        + 0.13 * chromatic_neutrality
        + 0.10 * edge_support
        + 0.14 * building_adj
        + 0.06 * vegetation_adj
        + 0.10 * bright_adjacency
    )
    contextual_support = (building_adj > 0) | (vegetation_adj > 0) | (bright_adjacency > 0) | ((edge_support > 0.55) & (chromatic_neutrality > 0.50))
    corroborated = (illumination_drop > 0.20) & contextual_support
    probability = np.where(corroborated, probability, probability * 0.35)
    # Uniform dark water is not a cast shadow. Water may still be shadowed when
    # it has a clear illumination drop relative to neighboring water pixels.
    water_shadow_support = water_support & (within_water_drop > 0.28) & contextual_support
    probability = np.where(water_support & ~water_shadow_support, probability * 0.20, probability)
    probability = np.where(water_shadow_support, np.maximum(probability, 0.42 + 0.45 * within_water_drop), probability)
    probability = np.clip(probability, 0.0, 1.0).astype("float32")
    probability[~valid] = 0.0

    shadow_type = np.full(valid.shape, ShadowType.NONE, dtype="uint8")
    likely = probability >= 0.52
    shadow_type[likely] = ShadowType.UNKNOWN_SHADOW
    shadow_type[likely & (bright_adjacency > 0)] = ShadowType.CLOUD_SHADOW
    shadow_type[likely & (vegetation_adj > 0)] = ShadowType.VEGETATION_SHADOW
    shadow_type[likely & (building_adj > 0)] = ShadowType.BUILDING_SHADOW

    # Water and shadow are allowed to overlap. Strong independent water evidence
    # is never removed from the illumination product.
    shaded_water_pixels = int((likely & (np.asarray(water_probability) >= 0.60)).sum()) if water_probability is not None else 0
    return ShadowEvidence(
        probability=probability,
        mask=likely & valid,
        shadow_type=shadow_type,
        features={
            "darkness": darkness.astype("float32"),
            "illumination_drop": illumination_drop.astype("float32"),
            "chromatic_neutrality": chromatic_neutrality.astype("float32"),
            "edge_support": edge_support.astype("float32"),
            "building_adjacency": building_adj,
            "vegetation_adjacency": vegetation_adj,
            "bright_object_adjacency": bright_adjacency,
            "within_water_illumination_drop": within_water_drop,
        },
        details={"method":"rgb_local_illumination_geometry_v1", "shaded_water_pixels": shaded_water_pixels},
    )


def multispectral_shadow_evidence(
    brightness: np.ndarray,
    valid: np.ndarray,
    spectral_water_probability: np.ndarray,
    *,
    vegetation_probability: np.ndarray | None = None,
    builtup_probability: np.ndarray | None = None,
) -> ShadowEvidence:
    brightness = np.asarray(brightness, dtype="float32")
    valid = np.asarray(valid, dtype=bool)
    local = ndi.uniform_filter(np.where(valid, brightness, 0.0), 21)
    density = ndi.uniform_filter(valid.astype("float32"), 21)
    local = local / np.maximum(density, 1e-4)
    illumination_drop = np.clip((local - brightness) / np.maximum(local, 0.01), 0.0, 1.0)
    finite_values = brightness[valid]
    dark_scale = float(np.percentile(finite_values, 35)) if finite_values.size else 0.0
    darkness = np.clip((dark_scale - brightness) / max(dark_scale, 1e-4), 0.0, 1.0)
    water = np.asarray(spectral_water_probability, dtype="float32")
    nonwater_support = 1.0 - water
    vegetation = np.asarray(vegetation_probability, dtype="float32") if vegetation_probability is not None else np.zeros_like(water)
    builtup = np.asarray(builtup_probability, dtype="float32") if builtup_probability is not None else np.zeros_like(water)
    context = np.maximum(vegetation, builtup)
    probability = np.clip(0.40 * illumination_drop + 0.20 * darkness + 0.22 * nonwater_support + 0.18 * context, 0.0, 1.0)
    corroborated = (illumination_drop > 0.22) & ((nonwater_support > 0.55) | (context > 0.45))
    probability = np.where(corroborated, probability, probability * 0.30).astype("float32")
    probability[~valid] = 0.0
    mask = (probability >= 0.52) & valid
    shadow_type = np.full(valid.shape, ShadowType.NONE, dtype="uint8")
    shadow_type[mask] = ShadowType.UNKNOWN_SHADOW
    shadow_type[mask & (vegetation >= builtup) & (vegetation > 0.45)] = ShadowType.VEGETATION_SHADOW
    shadow_type[mask & (builtup > vegetation) & (builtup > 0.45)] = ShadowType.BUILDING_SHADOW
    return ShadowEvidence(
        probability=probability, mask=mask, shadow_type=shadow_type,
        features={"darkness":darkness.astype("float32"), "illumination_drop":illumination_drop.astype("float32"), "nonwater_support":nonwater_support.astype("float32")},
        details={"method":"multispectral_illumination_spectral_disagreement_v1", "shaded_water_pixels":int((mask & (water >= 0.60)).sum())},
    )