File size: 7,957 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
"""Resolve spectral roles from evidence, never from an unqualified band number."""
from dataclasses import dataclass, field, asdict
import re


def normalize(value):
    return re.sub(r"[^a-z0-9]", "", str(value).lower())


ALIASES = {
    "blue": {"blue"}, "green": {"green"}, "red": {"red"},
    "nir": {"nir", "nearinfrared", "nir08"},
    "narrow_nir": {"narrownir", "nirnarrow", "nir09", "b8a"},
    "swir": {"swir", "swir1", "swir16", "shortwaveinfrared1"},
    "swir2": {"swir2", "swir22", "shortwaveinfrared2"},
    "vv": {"vv"}, "vh": {"vh"}, "hh": {"hh"}, "hv": {"hv"},
}
S2 = {"b2":"blue", "b3":"green", "b4":"red", "b8":"nir", "b8a":"narrow_nir", "b11":"swir", "b12":"swir2"}
LANDSAT_OLI = {"b2":"blue", "b3":"green", "b4":"red", "b5":"nir", "b6":"swir", "b7":"swir2"}
LANDSAT_TM = {"b1":"blue", "b2":"green", "b3":"red", "b4":"nir", "b5":"swir", "b7":"swir2"}


@dataclass
class BandEntry:
    band_name: str
    raster_index: int
    wavelength: float | None = None
    confidence: float = 1.0
    source: str = "metadata"

    def to_dict(self):
        return {**asdict(self), "band_index": self.raster_index}


@dataclass
class BandMap:
    indices: dict = field(default_factory=dict)
    confidence: dict = field(default_factory=dict)
    evidence: dict = field(default_factory=dict)
    wavelengths_nm: dict = field(default_factory=dict)
    warnings: list = field(default_factory=list)
    bands: dict = field(default_factory=dict)

    def has_band(self, name: str) -> bool:
        return name.lower() in self.indices

    def get_index(self, name: str) -> int | None:
        return self.indices.get(name.lower())

    def to_dict(self):
        d = asdict(self)
        d["bands"] = {k: v.to_dict() if hasattr(v, "to_dict") else v for k, v in self.bands.items()}
        return d


def detect_band_map(src):
    result = BandMap()
    tags = {normalize(k): v for k, v in src.tags().items()}
    sensor = normalize(" ".join(str(tags.get(k, "")) for k in ("sensor", "satellite", "platform")))
    declared_order = [item.strip() for item in str(tags.get("bandorder", "")).split(",")]
    if len(declared_order) != src.count:
        declared_order = []
    description_tokens = {normalize(value) for value in (*src.descriptions, *declared_order) if value}
    numbered_descriptions = {f"b{int(match.group(1))}" for token in description_tokens
        if (match := re.fullmatch(r"(?:band|b)0*(\d+)", token))}
    if "sentinel2" not in sensor and (
        "b8a" in description_tokens
        or ({"b10", "b11", "b12"} <= description_tokens and len(description_tokens) >= 8)
        or {"b2", "b3", "b4", "b8", "b11", "b12"} <= numbered_descriptions
    ):
        sensor = f"{sensor} sentinel2_inferred_from_band_names"
    numbered = S2 if "sentinel2" in sensor else LANDSAT_OLI if any(x in sensor for x in ("landsat8", "landsat9")) else LANDSAT_TM if any(x in sensor for x in ("landsat4", "landsat5", "landsat7")) else {}
    candidates = {}
    exact_s2_candidates = {}
    conflicts = set()
    for i in range(1, src.count + 1):
        band_tags = {normalize(k): v for k, v in src.tags(i).items()}
        names = [src.descriptions[i - 1] or ""] + ([declared_order[i - 1]] if declared_order else []) + [band_tags[k] for k in ("name", "commonname", "bandname", "polarization", "polarisation") if k in band_tags]
        # Color interpretation is authoritative for ordinary RGB files.
        color = src.colorinterp[i - 1].name
        if color in ("red", "green", "blue"):
            names.append(color)
        roles = set()
        reasons = []
        for name in names:
            key = normalize(name)
            s2_match = re.fullmatch(r"(?:band|b)0*(1[0-2]|[1-9]|8a)", key)
            if "sentinel2" in sensor and s2_match:
                token = s2_match.group(1)
                exact_name = "b8a" if token == "8a" else f"b{int(token):02d}"
                exact_s2_candidates.setdefault(exact_name, []).append((i, f"Sentinel-2 metadata: {name}"))
            for role, aliases in ALIASES.items():
                if key in aliases:
                    roles.add(role)
                    if role == "swir":
                        roles.add("swir1")
                    reasons.append(f"band {i}: {name}")
            match = re.fullmatch(r"(?:band|b)0*(\d+)", key)
            if match:
                role = numbered.get("b" + match[1])
                if role:
                    roles.add(role)
                    if role == "swir":
                        roles.add("swir1")
                    reasons.append(f"{sensor}: {name}")
        wave = band_tags.get("wavelength", band_tags.get("centralwavelength"))
        unit = normalize(band_tags.get("wavelengthunits", band_tags.get("wavelengthunit", "")))
        if wave is not None and unit in {"nm", "nanometer", "nanometers", "um", "micrometer", "micrometers"}:
            try:
                nm = float(wave) * (1000 if unit in {"um", "micrometer", "micrometers"} else 1)
                result.wavelengths_nm[str(i)] = nm
                for role, lo, hi in [("blue", 450, 510), ("green", 520, 600), ("red", 630, 690), ("narrow_nir", 855, 890), ("nir", 780, 854), ("swir", 1550, 1750), ("swir2", 2080, 2350)]:
                    if lo <= nm <= hi:
                        roles.add(role)
                        if role == "swir":
                            roles.add("swir1")
                        reasons.append(f"wavelength {nm:g} nm")
            except (ValueError, TypeError):
                result.warnings.append(f"Invalid wavelength for band {i}.")
        if len(roles) > 1 and not (roles == {"swir", "swir1"}):
            conflicts.update(roles)
            result.warnings.append(f"Conflicting role evidence for band {i}: {sorted(roles)}.")
        for role in roles:
            candidates.setdefault(role, []).append((i, reasons))
    for role, choices in candidates.items():
        unique_indices = {choice[0] for choice in choices}
        if role in conflicts or len(unique_indices) != 1:
            result.warnings.append(f"Ambiguous {role} mapping; dependent operations are blocked.")
            continue
        idx = next(iter(unique_indices))
        reasons_str = "; ".join(dict.fromkeys(reason for _, reasons in choices for reason in reasons))
        result.indices[role] = idx
        result.confidence[role] = 1.0  # evidence completeness, not a calibrated probability
        result.evidence[role] = choices[0][1]
        wavelength_val = result.wavelengths_nm.get(str(idx))
        result.bands[role] = BandEntry(
            band_name=role.upper(),
            raster_index=idx,
            wavelength=wavelength_val,
            confidence=1.0,
            source=reasons_str or "metadata",
        )
    for role, choices in exact_s2_candidates.items():
        unique_indices = {choice[0] for choice in choices}
        if len(unique_indices) != 1:
            result.warnings.append(f"Ambiguous exact Sentinel-2 {role.upper()} mapping; model inference is blocked.")
            continue
        index = next(iter(unique_indices))
        result.indices[role] = index
        result.confidence[role] = 1.0
        result.evidence[role] = [choice[1] for choice in choices]
        result.bands[role] = BandEntry(
            band_name=role.upper(), raster_index=index, confidence=1.0,
            source="; ".join(choice[1] for choice in choices),
        )
    # Ensure swir and swir1 cross-alias
    if "swir" in result.indices and "swir1" not in result.indices:
        result.indices["swir1"] = result.indices["swir"]
        result.confidence["swir1"] = result.confidence["swir"]
        result.evidence["swir1"] = result.evidence["swir"]
    elif "swir1" in result.indices and "swir" not in result.indices:
        result.indices["swir"] = result.indices["swir1"]
        result.confidence["swir"] = result.confidence["swir1"]
        result.evidence["swir"] = result.evidence["swir1"]
    return result