martedì 29 settembre 2026

Cronaca di un fallimento: crisotilo su Enmap

Ho trovato un database pubblico di tetti in amianto pubblicato dalla Regione Piemonte ed ho provato ad incrociarlo con i dati di Enmap


 

 

Il primo test e' stato quello di estrarre le firme spettrali corrispondenti ad ogni pixel (dopo aver filtrato con  Savitzky Gorlay) usando Enmap Toolbox  con Extract spectral profiles from raster

 


 


 

 L'immagine e' stata convertita in formato Envi Bil con Save raster layer as con il profile Envi BIL

Visto che molti pixel non sono puri ho provato l'algoritmo MTMF che lavora sub pixel
Prendendo lo spettro medio della libreria ho provato a calcolare l'algoritmo su tutta l'immagine tramite lo script sottostante che produce una mappa di MF Score, una di Infeasibility, una mappa di sintesi delle due precedenti con score 0-100 ed una mappa a 3 classi

import numpy as np
import rasterio
from spectral.io import envi

NODATA_OUT = -9999.0


def load_target(lib_hdr_path):
    lib = envi.open(lib_hdr_path)
    sp = np.asarray(lib.spectra, dtype=np.float64)
    ok = np.isfinite(sp).all(axis=1) & (sp > -1000).all(axis=1)
    print(f"Spettri validi in libreria: {ok.sum()} su {sp.shape[0]}")
    return sp[ok]


def estimate_noise_cov(cube, valid2d):
    """Covarianza del rumore con shift-difference orizzontale. cube: (h, w, nb)."""
    d = cube[:, 1:, :] - cube[:, :-1, :]
    m = valid2d[:, 1:] & valid2d[:, :-1]
    d = d[m].astype(np.float64)
    return np.cov(d, rowvar=False) / 2.0


def run_mtmf(
    image_path,
    lib_hdr_path,
    out_path="mtmf_output_test2.bsq",
    lib_scale=None,        # None = auto (allinea la libreria all'immagine)
    mnf_min_eig=2.0,       # tieni componenti MNF con autovalore > soglia (SNR > ~1)
    max_comp=40,
    z_lo=3.0, z_hi=8.0,    # MF: z-score robusto dove il termine MF va da 0 a 1
    inf_p_lo=5, inf_p_hi=75,   # percentili di infeasibility: <=p_lo -> 1, >=p_hi -> 0
    class_thr=(25, 50, 75),    # soglie score per classi 1/2/3
    plot=True,
):
    # ---------- Immagine ----------
    with rasterio.open(image_path) as src:
        img = src.read().astype(np.float32)
        nb, h, w = img.shape
        meta = src.meta.copy()
        nodata = src.nodata
    pixels = np.transpose(img, (1, 2, 0)).reshape(-1, nb)
    del img

    valid = np.isfinite(pixels).all(axis=1)
    if nodata is not None:
        valid &= (pixels != nodata).all(axis=1)
    valid &= (pixels != 0).any(axis=1)
    print(f"Pixel validi: {valid.sum()} su {pixels.shape[0]}")

    # ---------- Target ----------
    spectra = load_target(lib_hdr_path)
    if spectra.shape[1] != nb:
        print("Numero di bande diverso tra immagine e libreria")
        return
    target = spectra.mean(axis=0)

    vmax = np.nanmax(pixels[valid][:, ::10])
    if lib_scale is None:
        lib_scale = 10000.0 if (vmax > 2 and np.nanmax(target) <= 2) else 1.0
    target = target * lib_scale
    print(f"Fattore scala libreria: {lib_scale}")

    # ---------- Bande utilizzabili ----------
    vp = pixels[valid]
    keep = (vp.std(axis=0) > 0) & np.isfinite(target)
    nk = int(keep.sum())
    print(f"Bande usate: {nk} su {nb}")
    vp = vp[:, keep].astype(np.float64)
    t = target[keep]

    # ---------- Rumore + MNF ----------
    cube_k = pixels[:, keep].reshape(h, w, nk)
    Cn = estimate_noise_cov(cube_k, valid.reshape(h, w))
    del cube_k, pixels

    s, U = np.linalg.eigh(Cn)
    s = np.clip(s, s.max() * 1e-8, None)
    P = U / np.sqrt(s)                      # sbianca il rumore

    mu = vp.mean(axis=0)
    Xc = vp - mu
    del vp
    Cd = (Xc.T @ Xc) / (Xc.shape[0] - 1)
    lam, V = np.linalg.eigh(P.T @ Cd @ P)
    order = np.argsort(lam)[::-1]
    lam, V = lam[order], V[:, order]
    T = P @ V                               # trasformazione MNF completa

    k = int(min(max_comp, (lam > mnf_min_eig).sum()))
    k = max(k, 3)
    print(f"Componenti MNF usate: {k} (autovalori: {np.round(lam[:k], 1)[:8]} ...)")

    Y = Xc @ T[:, :k]
    del Xc
    d = (t - mu) @ T[:, :k]                 # target nello spazio MNF (centrato)

    # ---------- Matched Filter ----------
    Sigma = np.cov(Y, rowvar=False)
    Sinv = np.linalg.pinv(Sigma)
    a = Sinv @ d
    denom = d @ a
    if not np.isfinite(denom) or denom <= 0:
        print("Denominatore non valido")
        return
    mf = (Y @ a) / denom                    # 0 = background, 1 = target

    # ---------- Infeasibility (unita' di sigma di rumore) ----------
    resid = Y - np.outer(mf, d)
    infeas = np.sqrt((resid ** 2).sum(axis=1) / k)
    del resid, Y

    # ---------- Score di sintesi ----------
    med = np.median(mf)
    mad = 1.4826 * np.median(np.abs(mf - med))
    z = (mf - med) / mad
    mf_term = np.clip((z - z_lo) / (z_hi - z_lo), 0, 1)

    lo, hi = np.percentile(infeas, [inf_p_lo, inf_p_hi])
    feas_term = 1.0 - np.clip((infeas - lo) / (hi - lo), 0, 1)

    score = 100.0 * mf_term * feas_term
    classes = np.digitize(score, class_thr).astype(np.float32)   # 0..3

    print(f"MF: mediana={med:.3f}, sigma robusto={mad:.3f}")
    print(f"Infeasibility: p{inf_p_lo}={lo:.2f}, p{inf_p_hi}={hi:.2f}")
    for c, name in enumerate(["nessuna", "bassa", "media", "alta"]):
        print(f"  classe {c} ({name}): {(classes == c).sum()} pixel")

    # ---------- Scrittura ----------
    def to_full(arr):
        out = np.full(h * w, NODATA_OUT, dtype=np.float32)
        out[valid] = arr.astype(np.float32)
        out[~np.isfinite(out)] = NODATA_OUT
        return out.reshape(h, w)

    meta.update(count=4, dtype="float32", driver="ENVI",
                interleave="bsq", nodata=NODATA_OUT)
    with rasterio.open(out_path, "w", **meta) as dst:
        dst.write(to_full(mf), 1)
        dst.write(to_full(infeas), 2)
        dst.write(to_full(score), 3)
        dst.write(to_full(classes), 4)
        for i, n in enumerate(["MF score", "Infeasibility (sigma)",
                               "MTMF score 0-100", "MTMF classe 0-3"], 1):
            dst.set_band_description(i, n)
    print(f"Salvato: {out_path}")

    # ---------- Scatter MF vs infeasibility ----------
    if plot:
        try:
            import matplotlib
            matplotlib.use("Agg")
            import matplotlib.pyplot as plt
            idx = np.random.default_rng(0).choice(mf.size, min(200000, mf.size), replace=False)
            plt.figure(figsize=(7, 5))
            plt.scatter(mf[idx], infeas[idx], s=1, c=score[idx], cmap="viridis", alpha=0.5)
            plt.colorbar(label="MTMF score")
            plt.xlabel("MF score")
            plt.ylabel("Infeasibility (sigma)")
            plt.xlim(np.percentile(mf, 0.1), np.percentile(mf, 99.99))
            plt.tight_layout()
            plt.savefig("mtmf_scatter.png", dpi=150)
            print("Salvato: mtmf_scatter.png")
        except Exception as e:
            print("Scatter non generato:", e)


if __name__ == "__main__":
    run_mtmf("test2.bil", "libreria.hdr")

 


 

se si incrocia la mappa dello score sintetico con i punti verita' a terra tramite lo script sottostante ho una corrispondenza di circa il 60% di positivi correttamente individuati a terra

import numpy as np
import rasterio
import geopandas as gpd
from scipy.ndimage import distance_transform_edt

RASTER = "mtmf_output_test2.bsq"
SHAPE = "amianto_PRA_pubblico.shp"
SCORE_MIN = 6.0     # soglia: score > 6
MAX_DIST = 1.5      # distanza massima in pixel (strettamente < MAX_DIST)
BAND = 3            # MTMF score

with rasterio.open(RASTER) as src:
    score = src.read(BAND).astype(np.float32)
    nodata = src.nodata
    crs = src.crs
    h, w = score.shape

valid = np.isfinite(score)
if nodata is not None:
    valid &= score != nodata

mask = valid & (score > SCORE_MIN)
print(f"Pixel con score > {SCORE_MIN}: {mask.sum()} su {valid.sum()} validi "
      f"({100 * mask.sum() / valid.sum():.2f}%)")

if mask.sum() == 0:
    raise SystemExit("Nessun pixel sopra soglia")

# distanza (in pixel) dal pixel "positivo" piu' vicino
dist = distance_transform_edt(~mask)

# ---------- Punti ----------
gdf = gpd.read_file(SHAPE)
print(f"Punti nello shapefile: {len(gdf)}  (CRS: {gdf.crs})")
if gdf.crs != crs:
    gdf = gdf.to_crs(crs)

with rasterio.open(RASTER) as src:
    xs = gdf.geometry.x.values
    ys = gdf.geometry.y.values
    rows, cols = rasterio.transform.rowcol(src.transform, xs, ys)
rows = np.asarray(rows)
cols = np.asarray(cols)

inside = (rows >= 0) & (rows < h) & (cols >= 0) & (cols < w)
d = np.full(len(gdf), np.nan)
on_valid = np.zeros(len(gdf), dtype=bool)
d[inside] = dist[rows[inside], cols[inside]]
on_valid[inside] = valid[rows[inside], cols[inside]]

hit = inside & on_valid & (d < MAX_DIST)

gdf["dist_px"] = d
gdf["hit"] = hit.astype(int)

n_in = int(inside.sum())
n_val = int((inside & on_valid).sum())
print(f"\nPunti dentro l'immagine: {n_in}")
print(f"Punti su pixel validi (non nodata): {n_val}")
print(f"Punti a < {MAX_DIST:g} pixel da un pixel con score > {SCORE_MIN}: "
      f"{hit.sum()} ({100 * hit.sum() / max(n_val, 1):.1f}% dei punti validi)")

# ---------- Baseline: quanto ci si aspetterebbe per caso ----------
area_frac = ((dist < MAX_DIST) & valid).sum() / valid.sum()
print(f"\nBaseline: {100 * area_frac:.1f}% dei pixel validi dell'immagine sta a "
      f"< {MAX_DIST:g} pixel da un pixel sopra soglia")
print(f"Attesi per puro caso: ~{area_frac * n_val:.0f} punti su {n_val}")

# ---------- Salvataggio ----------
gdf.to_file("amianto_PRA_mtmf_check.gpkg", driver="GPKG")
print("Salvato: amianto_PRA_mtmf_check.gpkg (campi dist_px, hit)")

 facendo una verifica con una immagine sulla quale la libreria non e' stata addestrata (la track vicina) la percentuale crolla ad misero 3% . 

Dopo un po' di ricerca il problema e' chiaro: MTMF usa la covarianza interna all'immagine, se si cambia l'immagine cambia il background e quindi l'addestramento non e' piu' utile

 Ho provato un approccio indipendente dall'immagine ovvero considerando la profondita' di picco tra 2320 e 2330 nm


#!/usr/bin/env python3
"""
Estrae il valore della profondita' di banda (raster prodotto da band_depth_2323.py)
nei punti di uno shapefile e calcola media e deviazione standard.

Uso
---
python extract_depth_at_points.py depth_2323.tif amianto_PRA_pubblico.shp
python extract_depth_at_points.py depth_wide.tif amianto_PRA_pubblico.shp --radius 1
python extract_depth_at_points.py depth_2323.tif punti.shp -o punti_depth.gpkg

NB: l'input raster e' il GeoTIFF di OUTPUT di band_depth_2323.py (es. depth_2323.tif),
non lo script .py.

Requisiti: numpy, rasterio, geopandas
"""
import argparse
import sys

import numpy as np
import rasterio
import geopandas as gpd


def neighborhood(arr, r, stat):
    """Statistica in una finestra quadrata (2r+1) ignorando i NaN.
    Il valore e' assegnato solo dove il pixel centrale e' valido."""
    if r == 0:
        return arr
    H, W = arr.shape
    pad = np.pad(arr, r, constant_values=np.nan)
    if stat == "max":
        out = np.full(arr.shape, np.nan, dtype=np.float32)
        for dy in range(2 * r + 1):
            for dx in range(2 * r + 1):
                out = np.fmax(out, pad[dy:dy + H, dx:dx + W])
    else:  # mean
        s = np.zeros(arr.shape, dtype=np.float64)
        n = np.zeros(arr.shape, dtype=np.float64)
        for dy in range(2 * r + 1):
            for dx in range(2 * r + 1):
                w = pad[dy:dy + H, dx:dx + W]
                ok = np.isfinite(w)
                s[ok] += w[ok]
                n[ok] += 1
        with np.errstate(invalid="ignore", divide="ignore"):
            out = (s / n).astype(np.float32)
    out[~np.isfinite(arr)] = np.nan
    return out


def describe(v):
    v = np.asarray(v, dtype=float)
    v = v[np.isfinite(v)]
    if v.size == 0:
        return None
    return dict(n=v.size, mean=v.mean(), std=v.std(ddof=1) if v.size > 1 else np.nan,
                median=np.median(v), p5=np.percentile(v, 5), p95=np.percentile(v, 95))


def fmt(name, d):
    if d is None:
        return f"{name}: nessun valore valido"
    return (f"{name}: n={d['n']}  media={d['mean']:.5f}  std={d['std']:.5f}  "
            f"mediana={d['median']:.5f}  p5={d['p5']:.5f}  p95={d['p95']:.5f}")


def main():
    ap = argparse.ArgumentParser(description=__doc__,
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("raster", help="GeoTIFF della profondita' di banda")
    ap.add_argument("points", help="shapefile puntuale")
    ap.add_argument("-o", "--output", help="file di uscita (.gpkg o .csv) con il valore per punto")
    ap.add_argument("--radius", type=int, default=0,
                    help="raggio in pixel della finestra attorno al punto (0 = solo il pixel del punto)")
    ap.add_argument("--radius-stat", choices=["max", "mean"], default="max",
                    help="statistica nella finestra se --radius > 0 (default: max)")
    args = ap.parse_args()

    with rasterio.open(args.raster) as ds:
        arr = ds.read(1).astype(np.float32)
        if ds.nodata is not None and np.isfinite(ds.nodata):
            arr[arr == ds.nodata] = np.nan
        arr[~np.isfinite(arr)] = np.nan
        H, W = arr.shape

        gdf = gpd.read_file(args.points)
        n_total = len(gdf)
        if gdf.crs is None:
            sys.exit("[ERRORE] Lo shapefile non ha CRS (.prj mancante).")
        gdf = gdf.explode(index_parts=False).reset_index(drop=True)
        if not (gdf.geom_type == "Point").all():
            sys.exit("[ERRORE] Lo shapefile deve contenere solo punti.")
        if ds.crs is not None and gdf.crs != ds.crs:
            print(f"Riproiezione dei punti da {gdf.crs} a {ds.crs}")
            gdf = gdf.to_crs(ds.crs)

        # coordinate -> indici di pixel con la trasformata inversa (indipendente
        # dalla versione di rasterio, che con gli array a volte fallisce)
        inv = ~ds.transform
        x = gdf.geometry.x.values
        y = gdf.geometry.y.values
        cols = np.floor(inv.a * x + inv.b * y + inv.c).astype(int)
        rows = np.floor(inv.d * x + inv.e * y + inv.f).astype(int)

    inside = (rows >= 0) & (rows < H) & (cols >= 0) & (cols < W)
    grid = neighborhood(arr, args.radius, args.radius_stat)

    value = np.full(len(gdf), np.nan, dtype=np.float32)
    value[inside] = grid[rows[inside], cols[inside]]
    valid = np.isfinite(value)

    print(f"Punti nello shapefile:       {n_total} (dopo explode: {len(gdf)})")
    print(f"Punti dentro l'immagine:     {int(inside.sum())}")
    print(f"Punti su pixel validi:       {int(valid.sum())}")
    if args.radius:
        print(f"Finestra: {2 * args.radius + 1}x{2 * args.radius + 1} pixel, statistica = {args.radius_stat}")
    print()

    d_pts = describe(value[valid])
    print(fmt("Per punto        ", d_pts))

    # punti che cadono nello stesso pixel: contarli una volta sola evita duplicati
    key = rows[valid] * W + cols[valid]
    _, first = np.unique(key, return_index=True)
    d_pix = describe(value[valid][first])
    print(fmt("Per pixel unico  ", d_pix))

    d_all = describe(grid[np.isfinite(grid)])
    print(fmt("Tutti i pixel    ", d_all))

    if d_pix and d_all and d_all["std"] > 0:
        eff = (d_pix["mean"] - d_all["mean"]) / d_all["std"]
        print(f"\nScarto della media (pixel unici) rispetto a tutta l'immagine: "
              f"{eff:+.2f} deviazioni standard dell'immagine")

    if args.output:
        out = gdf.copy()
        out["depth"] = value
        out["row"] = rows
        out["col"] = cols
        out["inside"] = inside
        out["valid"] = valid
        if args.output.lower().endswith(".csv"):
            out.drop(columns="geometry").assign(x=gdf.geometry.x, y=gdf.geometry.y) \
               .to_csv(args.output, index=False)
        else:
            out.to_file(args.output, driver="GPKG")
        print(f"\nSalvato: {args.output}")


if __name__ == "__main__":
    main()


 

 Dai risultati si vede che i punti si confondono con il background


 Il problema con questo approccio e' che il rumore domina il segnale oltre al fatto che l'assorbimento del crisotilo e' vicino a quello del carbonato del cemento

 

 

 

 

 

 

Nessun commento:

Posta un commento

Cronaca di un fallimento: crisotilo su Enmap

Ho trovato un database pubblico di tetti in amianto pubblicato dalla Regione Piemonte ed ho provato ad incrociarlo con i dati di Enmap    ...