mercoledì 22 luglio 2026

BRDF FigSpec FS60-C

Visto che nel volo ad Empoli era emersa l'importanza della BRDF nei dati acquisiti ho voluto provare con un volo precedente

Si tratta di un volo effettuato il 10 luglio 2026 ore 11:47 presso San Rossore (43.7243059,10.2791424) 

L'azimuth medio e' di 345.6 gradi 

Il Sole aveva azimuth 129.1° ed  elevazione 60.6°  

Lo scarto tra la track di volo ed il piano principale solare e' di 53.5 gradi (molto meglio rispetto ad Empoli l'angolo era di circa 8 gradi)

Usando il modello RPF si ha che tra i due lati dell'immagine si ha uno scarto di circa 11% contro il 23% di Empoli 

 Il problema in questo caso e' che si tratta di alternanza di tracce ascendenti e discendenti con stesso azimuth ...quindi si invertono ad ogni passate le condizioni di BRDF 

 

 

 

Ortorettifica FIgSpec FS-60C

Per georiferire il dato di FigSpec FS60-C si puo' usare il file figspec.gps

(da notare che il file gps e' un csv che include anche dei campi sull'assetto di volo del drone yaw,pitch and roll che sono valorizzati sempre a zero) 

Da un primo volo con una risoluzione spaziale a 480 px, 1 nm e quota di volo 120 m AGL si ha un GSD di 5 cm al Nadir 

Considerando che il volo ha ripreso 135.5  m x 68.8 m quindi circa 9322 metri quadri si ha una produzione di 88 Mb a metro quadro

python3 orthorectify_pushbroom.py true_color.png figspec.gps --flip-across-track --fov 12.375

 

#!/usr/bin/env python3
"""
orthorectify_pushbroom.py

Ortorettifica un'immagine true-color pushbroom usando la traccia GPS/IMU
(CSV) e il FOV noto del sensore, tramite georeferenziazione diretta (Direct
Georeferencing, DG) e resampling su una griglia regolare UTM.

Come funziona (in breve)
-------------------------
1. Interpola la posizione (lat, lon, altitudine) del velivolo per OGNI riga
dell'immagine, a partire dai fix GPS sparsi nel file CSV (che ne contiene
uno ogni N righe, non uno per riga).
2. Stima la prua/heading del volo dalla traccia GPS stessa (bearing tra punti
consecutivi, o un singolo heading globale se il volo e' una passata
dritta), dato che i campi Pitch/Roll/Yaw nel file GPS risultano spesso
non popolati (costanti a 0) nei log di alcuni sistemi.
3. Per ogni pixel (riga, colonna), calcola l'angolo di vista rispetto al
nadir (assumendo FOV a distribuzione angolare lineare/equiangolare sulle
colonne) e proietta il raggio di vista a terra assumendo TERRENO PIATTO
all'altitudine data (nessun DEM), con velivolo puntato a nadir (pitch=
roll=0, cioe' nessuna correzione di assetto oltre l'heading).
4. Converte le coordinate di terra cosi' ottenute (lat/lon per ogni pixel,
la "griglia di geolocazione") in coordinate UTM metriche, e ricampiona
(resample) i pixel sorgente su una griglia regolare in UTM per produrre
un'ortofoto vera e propria, salvata come GeoTIFF georeferenziato.

Assunzioni e limiti (importanti)
----------------------------------
- NESSUN modello del terreno (DEM): il terreno e' assunto piatto
all'altitudine di volo fornita. Su terreno con rilievo significativo
introduce "relief displacement" (spostamento radiale dal nadir
proporzionale al dislivello). Per un rilievo di pianura/campo agricolo
come questo, l'errore e' generalmente trascurabile.
- Nessuna correzione di boresight/misalignment tra IMU e camera (si assume
il sensore perfettamente allineato con l'asse di volo).
- Pitch e Roll sono assunti nulli (nadir stabilizzato) se non disponibili
nel file GPS o se costanti a zero (tipico quando quel canale non e'
popolato dal logger). Se il tuo file GPS ha valori di pitch/roll validi
(non tutti zero), lo script li usa automaticamente.
- Il FOV e' assunto equiangolare e lineare sulle colonne (buona
approssimazione per sensori pushbroom a stretto campo di vista).
- La direzione "sinistra/destra" del sensore rispetto alla direzione di
volo (cioe' se la colonna 0 e' a destra o sinistra del velivolo) NON e'
nota a priori: usa --flip-across-track se l'output risulta specchiato
rispetto alla realta' (confronta con una mappa/basemap nota).

Uso
---
python3 orthorectify_pushbroom.py true_color.png figspec.gps \\
--fov 24.75 --out ortho_true_color.tif

# se l'immagine esce specchiata lateralmente:
python3 orthorectify_pushbroom.py true_color.png figspec.gps \\
--fov 24.75 --flip-across-track --out ortho_true_color.tif

# con dimensione pixel di output forzata (metri):
python3 orthorectify_pushbroom.py true_color.png figspec.gps \\
--fov 24.75 --pixel-size 0.05 --out ortho_true_color.tif
"""

import argparse
import sys
from pathlib import Path

import numpy as np
import pandas as pd
from PIL import Image
import pyproj
import rasterio
from rasterio.transform import Affine
from scipy.ndimage import distance_transform_edt
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt


# ---------------------------------------------------------------------
# GPS: interpolazione posizione per riga + heading
# ---------------------------------------------------------------------

def load_and_interpolate_gps(csv_path, n_rows, line_col="Lines",
lat_col="Latitude", lon_col="Longitude", alt_col="Altitude"):
"""
Interpola lat/lon/altitudine per ogni riga dell'immagine (0..n_rows-1),
a partire dai fix GPS registrati (uno ogni N righe). Extrapolazione
costante (clamp) fuori dal range coperto dai fix, con avviso.
"""
df = pd.read_csv(csv_path).sort_values(line_col)
lines = df[line_col].values.astype(float)
lat = df[lat_col].values.astype(float)
lon = df[lon_col].values.astype(float)
alt = df[alt_col].values.astype(float)

row_idx = np.arange(n_rows, dtype=float)

if row_idx.min() < lines.min() or row_idx.max() > lines.max():
print(f"[attenzione] Le righe dell'immagine (0-{n_rows-1}) escono dal range coperto "
f"dai fix GPS ({lines.min():.0f}-{lines.max():.0f}); le righe fuori range "
f"useranno il valore del fix piu' vicino (estrapolazione costante).")

lat_i = np.interp(row_idx, lines, lat)
lon_i = np.interp(row_idx, lines, lon)
alt_i = np.interp(row_idx, lines, alt)

return lat_i, lon_i, alt_i, df


def heading_from_track(lat, lon, mode="global"):
"""
Stima l'heading (azimuth, gradi da nord) per ogni riga.
mode='global': un unico heading (PCA su tutta la traccia) per tutte le righe
- robusto, adatto a voli rettilinei (il caso tipico).
mode='local' : bearing punto-punto (differenza centrata), utile se il
volo non e' perfettamente dritto.
"""
n = len(lat)
lat0 = np.radians(np.mean(lat))
R = 6371000.0
x = np.radians(lon - lon.mean()) * R * np.cos(lat0)
y = np.radians(lat - lat.mean()) * R
pts = np.column_stack([x, y])

if mode == "global":
pts_c = pts - pts.mean(axis=0)
cov = np.cov(pts_c.T)
eigvals, eigvecs = np.linalg.eigh(cov)
main_dir = eigvecs[:, np.argmax(eigvals)]
az = np.degrees(np.arctan2(main_dir[0], main_dir[1])) % 360
# verifica il verso (potrebbe essere az o az+180): usa il verso del
# moto reale (dal primo all'ultimo punto) per orientarlo correttamente
overall = np.degrees(np.arctan2(x[-1] - x[0], y[-1] - y[0])) % 360
if min(abs(az - overall), 360 - abs(az - overall)) > 90:
az = (az + 180) % 360
return np.full(n, az)

# locale: bearing centrato, con gestione dei bordi
heading = np.zeros(n)
for i in range(n):
i0 = max(0, i - 1)
i1 = min(n - 1, i + 1)
dx = x[i1] - x[i0]
dy = y[i1] - y[i0]
heading[i] = np.degrees(np.arctan2(dx, dy)) % 360
return heading


# ---------------------------------------------------------------------
# Geometria pushbroom: da (riga, colonna) a (lat, lon) di terra
# ---------------------------------------------------------------------

def build_geolocation_grid(lat, lon, alt_agl, heading_deg, n_cols, fov_deg,
pitch_deg=None, roll_deg=None, flip_across_track=False):
"""
Calcola lat/lon di terra per ogni pixel (n_rows x n_cols), assumendo
terreno piatto e velivolo nadir-stabilizzato (salvo pitch/roll forniti).

Ritorna (lat_grid, lon_grid) shape (n_rows, n_cols).
"""
n_rows = len(lat)
half_fov = fov_deg / 2.0
col_idx = np.arange(n_cols)
center = (n_cols - 1) / 2.0
theta_c = (col_idx - center) / center * half_fov # gradi, - a sx, + a dx (o viceversa)
if flip_across_track:
theta_c = -theta_c
theta_c_rad = np.radians(theta_c)

if pitch_deg is None:
pitch_deg = np.zeros(n_rows)
if roll_deg is None:
roll_deg = np.zeros(n_rows)

R_earth = 6371000.0
lat_grid = np.empty((n_rows, n_cols))
lon_grid = np.empty((n_rows, n_cols))

psi = np.radians(heading_deg) # heading (yaw), rad
right_az = psi + np.pi / 2 # direzione "a destra" del volo (azimuth)

for r in range(n_rows):
h_agl = alt_agl[r]
if h_agl <= 0:
h_agl = np.nan # evita risultati assurdi se l'altitudine e' invalida

# NB: pitch/roll qui non applicati con rotazione completa (si assume
# nadir); un'estensione futura puo' aggiungere la rotazione 3D
# completa (roll attorno all'asse di avanzamento, pitch attorno
# all'asse trasversale) se il file GPS fornisce valori attendibili.
ground_offset = h_agl * np.tan(theta_c_rad) # metri, lungo l'asse across-track

north_off = ground_offset * np.cos(right_az[r])
east_off = ground_offset * np.sin(right_az[r])

dlat = (north_off / R_earth) * (180.0 / np.pi)
dlon = (east_off / (R_earth * np.cos(np.radians(lat[r])))) * (180.0 / np.pi)

lat_grid[r, :] = lat[r] + dlat
lon_grid[r, :] = lon[r] + dlon

return lat_grid, lon_grid


# ---------------------------------------------------------------------
# Proiezione UTM e resampling su griglia regolare
# ---------------------------------------------------------------------

def utm_epsg_from_lonlat(lon, lat):
zone = int((lon + 180) / 6) + 1
return (32600 + zone) if lat >= 0 else (32700 + zone)


def fill_small_gaps(image_uint8, filled_mask, max_fill_dist_px):
"""
Riempie i piccoli vuoti isolati (1-2 pixel) con il valore del pixel
valido piu' vicino, entro una distanza massima (in pixel di output).
Questo NON riempie le grandi zone nodata ai bordi/angoli del bounding
box (quelle sono corrette: lo swath e' un rettangolo ruotato rispetto
agli assi E/N della griglia UTM, quindi il bounding box allineato agli
assi contiene inevitabilmente due triangoli vuoti agli angoli - non e'
un difetto, e' la geometria della rotazione). Serve solo a colmare
micro-vuoti dovuti a piccola sotto-densita' locale di campionamento.
"""
empty = ~filled_mask
if not empty.any():
return image_uint8, filled_mask

dist, (idx_r, idx_c) = distance_transform_edt(empty, return_indices=True)
fillable = empty & (dist <= max_fill_dist_px)

out = image_uint8.copy()
new_mask = filled_mask.copy()
for ch in range(image_uint8.shape[2]):
channel = image_uint8[:, :, ch]
filled_channel = channel[idx_r, idx_c]
ch_out = out[:, :, ch]
ch_out[fillable] = filled_channel[fillable]
out[:, :, ch] = ch_out
new_mask[fillable] = True
return out, new_mask


def orthorectify(image_path, gps_csv, out_path, fov_deg, nodata_thresh=3.0,
pixel_size=None, flip_across_track=False, heading_mode="global",
agl_height_override=None, line_col="Lines", fill_gap_px=3):

img = Image.open(image_path).convert("RGB")
arr = np.asarray(img).astype(np.float64)
n_rows, n_cols = arr.shape[0], arr.shape[1]
print(f"Immagine caricata: {n_rows} righe x {n_cols} colonne")

luma = arr.mean(axis=2)
valid_mask = luma > nodata_thresh

lat, lon, alt, gps_df = load_and_interpolate_gps(gps_csv, n_rows, line_col=line_col)
if agl_height_override is not None:
alt = np.full(n_rows, agl_height_override)
print(f"Altitudine AGL forzata a {agl_height_override} m per tutte le righe")
else:
print(f"Altitudine AGL dal file GPS: min={alt.min():.1f} m, max={alt.max():.1f} m, media={alt.mean():.1f} m")

pitch = gps_df["Pitch"].values.astype(float) if "Pitch" in gps_df.columns else None
roll = gps_df["Roll"].values.astype(float) if "Roll" in gps_df.columns else None
if pitch is not None and np.allclose(pitch, 0) and roll is not None and np.allclose(roll, 0):
print("[info] Pitch/Roll nel file GPS sono costanti a zero (probabilmente non popolati): "
"assumo velivolo nadir-stabilizzato (nessuna correzione di assetto oltre l'heading).")
pitch_interp = roll_interp = None
elif pitch is not None and roll is not None:
row_idx = np.arange(n_rows, dtype=float)
lines = gps_df[line_col].values.astype(float)
pitch_interp = np.interp(row_idx, lines, pitch)
roll_interp = np.interp(row_idx, lines, roll)
print("[info] Uso i valori di Pitch/Roll forniti nel file GPS (non applicata rotazione 3D completa "
"in questa versione semplificata: solo heading applicato).")
else:
pitch_interp = roll_interp = None

heading = heading_from_track(lat, lon, mode=heading_mode)
print(f"Heading stimato: min={heading.min():.1f} deg, max={heading.max():.1f} deg "
f"({'costante (modo global)' if heading_mode == 'global' else 'variabile (modo local)'})")

print(f"FOV: {fov_deg:.2f} deg | flip_across_track={flip_across_track}")
lat_grid, lon_grid = build_geolocation_grid(
lat, lon, alt, heading, n_cols, fov_deg,
pitch_deg=pitch_interp, roll_deg=roll_interp, flip_across_track=flip_across_track
)

# --- proiezione in UTM ---
epsg = utm_epsg_from_lonlat(np.nanmean(lon_grid), np.nanmean(lat_grid))
print(f"CRS di output scelto: EPSG:{epsg} (UTM automatico dal baricentro dell'area)")
transformer = pyproj.Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
easting, northing = transformer.transform(lon_grid, lat_grid)

valid = valid_mask & np.isfinite(easting) & np.isfinite(northing)
if not valid.any():
raise ValueError("Nessun pixel valido da ortorettificare (controlla nodata_thresh o i dati GPS)")

e_min, e_max = easting[valid].min(), easting[valid].max()
n_min, n_max = northing[valid].min(), northing[valid].max()
print(f"Estensione ortofoto: E [{e_min:.1f}, {e_max:.1f}] m, N [{n_min:.1f}, {n_max:.1f}] m "
f"({e_max-e_min:.1f} x {n_max-n_min:.1f} m)")

if pixel_size is None:
# GSD approssimato al nadir: altitudine media * (FOV in rad / n_cols)
gsd = float(np.nanmean(alt)) * np.radians(fov_deg) / n_cols
pixel_size = max(gsd, 0.01)
print(f"Dimensione pixel di output (auto, da GSD al nadir): {pixel_size:.4f} m")
else:
print(f"Dimensione pixel di output (specificata): {pixel_size:.4f} m")

out_cols = int(np.ceil((e_max - e_min) / pixel_size)) + 1
out_rows = int(np.ceil((n_max - n_min) / pixel_size)) + 1
print(f"Dimensioni griglia di output: {out_rows} righe x {out_cols} colonne")

# --- binning (splat) dei pixel sorgente sulla griglia di output ---
col_out = ((easting - e_min) / pixel_size).astype(np.int64)
row_out = ((n_max - northing) / pixel_size).astype(np.int64) # riga 0 = Nord (northing massimo)

ok = valid & (col_out >= 0) & (col_out < out_cols) & (row_out >= 0) & (row_out < out_rows)

sum_rgb = np.zeros((out_rows, out_cols, 3), dtype=np.float64)
count = np.zeros((out_rows, out_cols), dtype=np.int64)

flat_row = row_out[ok]
flat_col = col_out[ok]
flat_idx = flat_row * out_cols + flat_col

for ch in range(3):
vals = arr[:, :, ch][ok]
sums = np.bincount(flat_idx, weights=vals, minlength=out_rows * out_cols)
sum_rgb[:, :, ch] = sums.reshape(out_rows, out_cols)
counts_flat = np.bincount(flat_idx, minlength=out_rows * out_cols)
count = counts_flat.reshape(out_rows, out_cols)

with np.errstate(invalid="ignore", divide="ignore"):
ortho = sum_rgb / count[:, :, np.newaxis]
nodata_out = count == 0
ortho[nodata_out] = 0
ortho_uint8 = np.clip(ortho, 0, 255).astype(np.uint8)

coverage_pct = 100 * (~nodata_out).sum() / nodata_out.size
print(f"Copertura griglia di output (prima del gap-fill): {coverage_pct:.1f}% delle celle riempite. "
f"NB: una copertura ben sotto il 100% e' normale e attesa: lo swath e' un rettangolo ruotato "
f"rispetto agli assi E/N della griglia UTM, quindi il bounding box allineato agli assi include "
f"inevitabilmente due triangoli vuoti agli angoli (non e' un artefatto di ricampionamento).")

if fill_gap_px > 0:
valid_out_mask = ~nodata_out
ortho_uint8, valid_out_mask = fill_small_gaps(ortho_uint8, valid_out_mask, fill_gap_px)
nodata_out = ~valid_out_mask
coverage_pct_after = 100 * valid_out_mask.sum() / valid_out_mask.size
print(f"Copertura dopo gap-fill dei soli micro-vuoti (raggio max {fill_gap_px} px): {coverage_pct_after:.1f}%")

# --- scrittura GeoTIFF ---
transform = Affine.translation(e_min, n_max) * Affine.scale(pixel_size, -pixel_size)
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)

with rasterio.open(
out_path, "w",
driver="GTiff",
height=out_rows, width=out_cols, count=3,
dtype=np.uint8, crs=f"EPSG:{epsg}", transform=transform,
nodata=0,
compress="deflate",
) as dst:
for ch in range(3):
dst.write(ortho_uint8[:, :, ch], ch + 1)

print(f"\nOrtofoto GeoTIFF salvata in: {out_path}")

# --- anteprima PNG rapida ---
preview_path = out_path.with_suffix(".preview.png")
fig, ax = plt.subplots(figsize=(8, 8 * out_rows / max(out_cols, 1)))
ax.imshow(ortho_uint8, extent=[e_min, e_min + out_cols * pixel_size,
n_max - out_rows * pixel_size, n_max])
ax.set_xlabel(f"Easting (m, EPSG:{epsg})")
ax.set_ylabel("Northing (m)")
ax.set_title("Anteprima ortofoto")
fig.tight_layout()
fig.savefig(preview_path, dpi=150)
plt.close(fig)
print(f"Anteprima salvata in: {preview_path}")

return out_path


def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("image", help="Immagine true-color pushbroom (PNG/JPEG/TIFF)")
parser.add_argument("gps_csv", help="File CSV GPS/IMU (con colonne Lines, Latitude, Longitude, Altitude, ...)")
parser.add_argument("--out", default="ortho.tif", help="Percorso del GeoTIFF di output")
parser.add_argument("--fov", type=float, required=True, help="FOV across-track totale del sensore (gradi)")
parser.add_argument("--nodata-thresh", type=float, default=3.0, help="Soglia luma per nodata (default 3.0)")
parser.add_argument("--pixel-size", type=float, default=None, help="Dimensione pixel output in metri (default: auto da GSD al nadir)")
parser.add_argument("--flip-across-track", action="store_true",
help="Inverte il lato sinistra/destra (usa se l'output risulta specchiato)")
parser.add_argument("--heading-mode", choices=["global", "local"], default="global",
help="'global': heading unico stimato via PCA su tutta la traccia (default, adatto a voli dritti). "
"'local': bearing punto-punto (per voli non rettilinei)")
parser.add_argument("--agl-height", type=float, default=None,
help="Forza l'altitudine AGL (m) per tutte le righe invece di usare la colonna Altitude del GPS")
parser.add_argument("--line-col", default="Lines", help="Nome della colonna indice-riga nel CSV GPS (default: Lines)")
parser.add_argument("--fill-gap-px", type=int, default=3,
help="Raggio massimo (in pixel di output) per il gap-fill nearest-neighbor "
"dei piccoli vuoti da aliasing griglia ruotata (default: 3, 0=disattiva)")
args = parser.parse_args()

orthorectify(args.image, args.gps_csv, args.out, args.fov,
nodata_thresh=args.nodata_thresh, pixel_size=args.pixel_size,
flip_across_track=args.flip_across_track, heading_mode=args.heading_mode,
agl_height_override=args.agl_height, line_col=args.line_col,
fill_gap_px=args.fill_gap_px)


if __name__ == "__main__":
main()


 

martedì 21 luglio 2026

Vignettatura FigSpec FS60-C (2)

TLDR: volare sempre orientati con l'azimuth del Sole 

l'azimuth del Sole cambia con il giorno e l'ora.All'alba ed al tramonto e' molto vicino a Est ed Ovest ma a mezzogiorno e' orientato verso Sud per la latitudini settentrionali 

Frugando sulla chiavetta USB del software del sensore ho trovato un file denominato FS6261009镜头系数 che tradotto suona come "coefficiente dell'obbiettivo"....aprendolo si vede che si tratta di un file json in cui sono inseriti due array (uno monodimensionale di 480 valori ed uno bidimensionale 72x480)

 


il grafico assomiglia ad una correzione di vignettatura ma e' solo per una banda (sembra la 121) 

Ho provato ad usare questa informazione per correggere la vignettatura ma nonostante la correzione l'effetto al bordo non e' stato eliminato ..l'ipotesi successiva e' che non sia qualcosa di relativo al sensore ma alla geometria di ripresa

Dai dati GPS si ha 

Punti usati: 166
Lunghezza traccia: 134.1 m
Azimuth linea di volo: 251.86 deg (reciproco: 71.86 deg)
Direzione across-track (perpendicolare al volo): 341.86 deg / 161.86 deg
 

Al giorno e l'ora di ripresa il Sole aveva una elevazione di 66 gradi ed un azimuth, quindi la traiettoria di volo era praticamente parallela al direzione del Sole

Il sensore della camera ha una FOV di 24.75 gradi (IFOV 0.9 mrad × 480 canali spaziali) 

  


Il fenomeno ai bordi quindi puo' essere non solo la vignettatura ma anche il forward scattering ed il backscattering che generano il fenomeno di hotspot BRDF

In pratica la direzione di ripresa e' tale che in una direzione l'ombra dell'oggetto e' coperta dall'oggetto stesso (back scattering, massima riflettanza hotspot propriamente detto, guardando dalla parte opposta dal Sole) mentre nella direzione opposta si osserva l'ombra piuttosto che l'oggetto stesso (forward scattering, guardando verso il Sole)

Il fenomeno affligge tutti i sensori iperspettrali sia da satellite che airborne ed e' particolarmente importante quanto piu' grande e' l'angolo di field of view. Un satellite che ha tipicamente questo problema e' Modis con una FOV di 55 gradi con una correzione basata sul modello a kernel RossThick-LiSparse. Lo stesso fenomeno si puo' verificare in analisi di immagini multitemporali anche di satelliti a bassa FOV (tipo Landsat 15 gradi) pur essendo eliosincroni

Altrimenti per una correzione piu' semplice viene utilizzato il modello RPV — Rahman-Pinty-Verstraete  

 

 #!/usr/bin/env python3
"""
brdf_hotspot_validation.py

Conferma quantitativa (non solo qualitativa) dell'ipotesi che l'asimmetria
sinistra/destra osservata nel profilo across-track del true-color sia
spiegabile con l'effetto hotspot/anti-hotspot BRDF, usando il modello
semi-empirico RPV (Rahman-Pinty-Verstraete) — lo stesso tipo di modello
usato in letteratura (es. MISR, POLDER) per descrivere la riflettanza
bidirezionale di superfici naturali includendo l'hotspot.

Idea generale
-------------
1. Dalla traccia GPS (o da un azimuth di volo fornito manualmente) si
   ricava la direzione across-track (perpendicolare al volo).
2. Nota la posizione del sole (azimuth, elevazione) e il FOV del sensore,
   si calcola per ogni colonna dello swath la geometria di vista (zenith e
   azimuth relativo al sole) e si predice con RPV il fattore BRDF atteso
   (normalizzato, cioe' solo la FORMA della variazione, non il valore
   assoluto di riflettanza).
3. Dal true-color si estrae il profilo across-track empirico (luma media
   per colonna), e se disponibile un file di calibrazione vignettatura
   (vendor, vedi analyze_crosstrack_illumination.py --vignette-json) lo si
   usa per "scorporare" la componente ottica (vignettatura) da quella di
   scena (BRDF), isolando il residuo asimmetrico da confrontare col modello.
4. Si calcola la correlazione tra la forma prevista da RPV e il residuo
   osservato: un'alta correlazione conferma quantitativamente che
   l'asimmetria e' spiegabile con l'hotspot/anti-hotspot, non con un
   artefatto strumentale.

Il modello RPV (riassunto)
---------------------------
R(theta_i, theta_r, phi) = rho0 * M(theta_i,theta_r) * F(g,Theta) * H(G)

  M(theta_i,theta_r) = (cos(theta_i) cos(theta_r) (cos(theta_i)+cos(theta_r)))^(k-1)
      -> termine di forma "bowl/dome"; k=1 lo rende neutro (default)

  F(g,Theta) = (1-Theta^2) / (1 + 2*Theta*cos(g) + Theta^2)^1.5
      -> funzione di fase di Henyey-Greenstein; g = angolo di scattering;
         Theta<0 favorisce il backscattering (tipico canopy vegetale)

  H(G) = 1 + (1-h) / (1 + G/h)
      -> funzione hotspot; G = sqrt(tan^2(theta_i)+tan^2(theta_r)
         -2*tan(theta_i)*tan(theta_r)*cos(phi)) e' la "distanza" in spazio
         tangente tra le direzioni sole e sensore (G=0 esattamente
         all'hotspot); h controlla quanto e' "stretto" il picco (tipico
         0.05-0.3 per canopy vegetali)

rho0 viene posto a 1 perche' qui interessa solo la FORMA relativa, non il
valore assoluto di riflettanza.

Uso
---
    python3 brdf_hotspot_validation.py true_color.png \\
        --gps-csv figspec.gps \\
        --sun-azimuth 154 --sun-elevation 66 \\
        --vignette-json test.json \\
        --out out_brdf

    # oppure con azimuth di volo manuale invece del GPS:
    python3 brdf_hotspot_validation.py true_color.png \\
        --flight-azimuth 251.86 --sun-azimuth 154 --sun-elevation 66 --out out_brdf
"""

import argparse
import json
import sys
from pathlib import Path

import numpy as np
from PIL import Image
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt


# ---------------------------------------------------------------------
# Geometria di volo (azimuth) dai punti GPS
# ---------------------------------------------------------------------

def flight_azimuth_from_gps(csv_path, lat_col="Latitude", lon_col="Longitude", delimiter=","):
    import pandas as pd
    df = pd.read_csv(csv_path, delimiter=delimiter)
    lat = df[lat_col].values.astype(float)
    lon = df[lon_col].values.astype(float)

    lat0 = np.radians(lat.mean())
    R = 6371000.0
    x = np.radians(lon - lon.mean()) * R * np.cos(lat0)
    y = np.radians(lat - lat.mean()) * R
    pts = np.column_stack([x, y])
    pts_centered = pts - pts.mean(axis=0)

    cov = np.cov(pts_centered.T)
    eigvals, eigvecs = np.linalg.eigh(cov)
    main_dir = eigvecs[:, np.argmax(eigvals)]
    az = np.degrees(np.arctan2(main_dir[0], main_dir[1])) % 360
    return az


# ---------------------------------------------------------------------
# Modello RPV
# ---------------------------------------------------------------------

def rpv_reflectance(theta_i_deg, theta_r_deg, phi_deg, k=1.0, theta_hg=-0.15, h=0.1, rho0=1.0):
    """
    Calcola il fattore di riflettanza bidirezionale RPV (non normalizzato
    in valore assoluto, rho0 e' un fattore di scala arbitrario).

    theta_i_deg : zenith solare (scalare, gradi)
    theta_r_deg : zenith di vista (array, gradi)
    phi_deg     : azimuth relativo vista-sole (array, gradi) = az_vista - az_sole
    k           : parametro di forma bowl/dome (1.0 = neutro)
    theta_hg    : parametro di asimmetria della fase di Henyey-Greenstein (-1..1)
    h           : parametro di nitidezza dell'hotspot (piu' piccolo = picco piu' stretto)
    rho0        : fattore di scala (irrilevante se si normalizza il risultato)
    """
    ti = np.radians(theta_i_deg)
    tr = np.radians(theta_r_deg)
    phi = np.radians(phi_deg)

    cos_ti, cos_tr = np.cos(ti), np.cos(tr)
    sin_ti, sin_tr = np.sin(ti), np.sin(tr)

    # angolo di scattering (fase)
    cos_g = cos_ti * cos_tr + sin_ti * sin_tr * np.cos(phi)
    cos_g = np.clip(cos_g, -1.0, 1.0)

    # funzione di fase Henyey-Greenstein
    F = (1 - theta_hg**2) / (1 + 2 * theta_hg * cos_g + theta_hg**2) ** 1.5

    # funzione hotspot (spazio tangente)
    tan_ti, tan_tr = np.tan(ti), np.tan(tr)
    G = np.sqrt(np.clip(tan_ti**2 + tan_tr**2 - 2 * tan_ti * tan_tr * np.cos(phi), 0, None))
    H = 1 + (1 - h) / (1 + G / h)

    # termine di forma bowl/dome
    M = (cos_ti * cos_tr * (cos_ti + cos_tr)) ** (k - 1)

    R = rho0 * M * F * H
    return R, np.degrees(np.arccos(cos_g)), G


# ---------------------------------------------------------------------
# Profilo across-track empirico dal true-color
# ---------------------------------------------------------------------

def empirical_column_profile(image_path, nodata_thresh=3.0):
    img = Image.open(image_path).convert("RGB")
    arr = np.asarray(img).astype(np.float64)
    luma = arr.mean(axis=2)
    mask = luma > nodata_thresh
    cols = arr.shape[1]

    profile = np.full(cols, np.nan)
    for c in range(cols):
        if mask[:, c].any():
            profile[c] = luma[mask[:, c], c].mean()
    return profile


def load_vignette_gain(vignette_json, band_key, cols):
    with open(vignette_json, "r", encoding="utf-8") as f:
        data = json.load(f)
    coeffs = {e["Key"]: np.array(e["Value"], dtype=float) for e in data.get("Coefficients", [])}
    if not coeffs:
        raise ValueError(f"Nessun coefficiente trovato in {vignette_json}")
    key = band_key if band_key is not None and band_key in coeffs else list(coeffs.keys())[0]
    gain = coeffs[key]
    if len(gain) != cols:
        x_orig = np.linspace(0, 1, len(gain))
        x_new = np.linspace(0, 1, cols)
        gain = np.interp(x_new, x_orig, gain)
    return gain, key


def fit_symmetric_vignetting(profile):
    """
    Fallback se non e' disponibile un file di calibrazione vendor: stima
    la componente di vignettatura come un fit quadratico del profilo (bella
    approssimazione per una caduta di illuminazione a "campana" simmetrica),
    e ritorna il "gain" equivalente (1/vignetting_shape_normalizzata) cosi'
    l'interfaccia e' la stessa di load_vignette_gain.
    """
    cols = len(profile)
    x = np.arange(cols)
    valid = np.isfinite(profile)
    coeffs = np.polyfit(x[valid], profile[valid], deg=2)
    fitted = np.polyval(coeffs, x)
    fitted_norm = fitted / np.nanmean(fitted)
    gain = 1.0 / fitted_norm
    return gain, None


# ---------------------------------------------------------------------
# Analisi principale
# ---------------------------------------------------------------------

def analyze(image_path, out_dir, sun_azimuth, sun_elevation, fov_deg,
            flight_azimuth=None, gps_csv=None, vignette_json=None, band_key=None,
            rpv_k=1.0, rpv_theta=-0.15, rpv_h=0.1, nodata_thresh=3.0):

    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # --- 1. azimuth di volo ---
    if flight_azimuth is None:
        if gps_csv is None:
            raise ValueError("Serve --flight-azimuth oppure --gps-csv per determinare la geometria di volo")
        flight_azimuth = flight_azimuth_from_gps(gps_csv)
        print(f"Azimuth di volo stimato dai punti GPS: {flight_azimuth:.2f} deg")
    else:
        print(f"Azimuth di volo (fornito manualmente): {flight_azimuth:.2f} deg")

    across_1 = (flight_azimuth + 90) % 360
    across_2 = (flight_azimuth - 90) % 360
    print(f"Direzioni across-track: {across_1:.2f} deg / {across_2:.2f} deg")

    theta_i = 90 - sun_elevation
    print(f"Sole: azimuth={sun_azimuth:.1f} deg, elevazione={sun_elevation:.1f} deg (zenith={theta_i:.1f} deg)")

    def fold180(a):
        a = a % 360
        return min(a, 360 - a)

    d1 = fold180(across_1 - sun_azimuth)
    d2 = fold180(across_2 - sun_azimuth)
    print(f"Scarto tra direzioni across-track e azimuth solare: {d1:.1f} deg / {d2:.1f} deg")

    # --- 2. profilo empirico dal true-color ---
    profile = empirical_column_profile(image_path, nodata_thresh)
    cols = len(profile)
    x = np.arange(cols)
    center = (cols - 1) / 2.0

    # --- 3. geometria di vista per colonna ---
    half_fov = fov_deg / 2.0
    view_zenith = np.abs((x - center) / center) * half_fov  # 0 al centro, half_fov ai bordi

    # due possibili assegnazioni lato<->azimuth (non sappiamo a priori quale
    # lato del sensore corrisponde a quale bearing fisico, dipende dal
    # montaggio/scansione del sensore: testiamo entrambe e scegliamo quella
    # che correla meglio con l'osservato, riportando comunque i risultati
    # di entrambe per trasparenza)
    offset = x - center
    view_az_A = np.where(offset < 0, across_1, across_2)
    view_az_B = np.where(offset < 0, across_2, across_1)

    results = {}
    for label, view_az in [("A", view_az_A), ("B", view_az_B)]:
        phi = view_az - sun_azimuth
        R, phase_angle, G = rpv_reflectance(theta_i, view_zenith, phi, k=rpv_k, theta_hg=rpv_theta, h=rpv_h)
        results[label] = {
            "view_az": view_az,
            "R": R,
            "phase_angle": phase_angle,
            "G": G,
        }

    # --- 4. vignettatura (per isolare il residuo BRDF dall'empirico) ---
    if vignette_json is not None:
        gain, used_key = load_vignette_gain(vignette_json, band_key, cols)
        vign_source = f"file vendor ({vignette_json}, Key={used_key})"
    else:
        gain, used_key = fit_symmetric_vignetting(profile)
        vign_source = "fit quadratico simmetrico (fallback, nessun file vendor fornito)"
    vignetting_shape = 1.0 / gain
    vignetting_shape_norm = vignetting_shape / np.nanmean(vignetting_shape)
    print(f"Componente di vignettatura stimata da: {vign_source}")

    empirical_norm = profile / np.nanmean(profile)
    residual_empirical = empirical_norm / vignetting_shape_norm
    residual_empirical_norm = residual_empirical / np.nanmean(residual_empirical)

    # --- 5. confronto modello vs osservato, per entrambe le assegnazioni ---
    def compare(model_R):
        model_norm = model_R / np.nanmean(model_R)
        valid = np.isfinite(residual_empirical_norm) & np.isfinite(model_norm)
        r = np.corrcoef(residual_empirical_norm[valid], model_norm[valid])[0, 1]
        rmse = np.sqrt(np.nanmean((residual_empirical_norm[valid] - model_norm[valid]) ** 2))
        # combinato: vignettatura * BRDF, confrontato con il profilo grezzo (non scorporato)
        combined = model_norm * vignetting_shape_norm
        combined_norm = combined / np.nanmean(combined)
        r_combined = np.corrcoef(empirical_norm[valid], combined_norm[valid])[0, 1]
        return model_norm, r, rmse, combined_norm, r_combined

    lines = []
    lines.append(f"Immagine: {image_path}")
    lines.append(f"Colonne (samples): {cols}")
    lines.append(f"FOV assunto: {fov_deg:.2f} deg (default: da datasheet FS-60, IFOV 0.9 mrad x 480 samples)")
    lines.append(f"Azimuth di volo: {flight_azimuth:.2f} deg")
    lines.append(f"Direzioni across-track: {across_1:.2f} / {across_2:.2f} deg")
    lines.append(f"Sole: azimuth={sun_azimuth:.1f} deg, elevazione={sun_elevation:.1f} deg (zenith={theta_i:.1f} deg)")
    lines.append(f"Scarto tra direzioni across-track e azimuth solare: {d1:.1f} deg / {d2:.1f} deg")
    lines.append(f"Parametri RPV: k={rpv_k}, Theta={rpv_theta}, h={rpv_h}")
    lines.append(f"Vignettatura stimata da: {vign_source}")
    lines.append("")

    best_label, best_r = None, -2
    for label in ("A", "B"):
        view_az = results[label]["view_az"]
        model_norm, r, rmse, combined_norm, r_combined = compare(results[label]["R"])
        results[label]["model_norm"] = model_norm
        results[label]["r_residual"] = r
        results[label]["rmse_residual"] = rmse
        results[label]["combined_norm"] = combined_norm
        results[label]["r_combined"] = r_combined

        side_desc = f"colonne basse -> {view_az[0]:.1f} deg, colonne alte -> {view_az[-1]:.1f} deg"
        lines.append(f"--- Assegnazione {label} ({side_desc}) ---")
        lines.append(f"  Correlazione modello BRDF vs residuo empirico (vignettatura scorporata): r={r:.3f} (RMSE={rmse:.4f})")
        lines.append(f"  Correlazione modello combinato (vignettatura x BRDF) vs profilo grezzo: r={r_combined:.3f}")
        lines.append(f"  Range angolo di fase coperto dal FOV: {results[label]['phase_angle'].min():.1f} - {results[label]['phase_angle'].max():.1f} deg")
        lines.append(f"  Range fattore hotspot G (0=hotspot esatto): {results[label]['G'].min():.3f} - {results[label]['G'].max():.3f}")
        lines.append("")

        if r > best_r:
            best_r = r
            best_label = label

    lines.append(f"Assegnazione con correlazione migliore: {best_label} (r={best_r:.3f})")
    hotspot_zenith_in_fov = theta_i <= half_fov
    lines.append(f"Zenith solare ({theta_i:.1f} deg) {'RIENTRA' if hotspot_zenith_in_fov else 'NON rientra'} "
                 f"nel range di zenith di vista coperto dal FOV (0-{half_fov:.1f} deg): "
                 f"{'ci si aspetta un picco hotspot pieno vicino al bordo' if hotspot_zenith_in_fov else 'ci si aspetta solo un aumento progressivo verso il bordo, senza raggiungere il picco pieno'}")

    report_text = "\n".join(lines)
    print("\n" + report_text)
    (out_dir / "report.txt").write_text(report_text, encoding="utf-8")

    # --- Figura 1: confronto residuo empirico vs modello BRDF (entrambe le assegnazioni) ---
    fig, ax = plt.subplots(figsize=(10, 5.5))
    ax.plot(x, residual_empirical_norm, color="black", linewidth=1.8, label="residuo empirico (vignettatura scorporata)")
    ax.plot(x, results["A"]["model_norm"], color="crimson", linestyle="--",
            label=f"modello RPV - assegnazione A (r={results['A']['r_residual']:.3f})")
    ax.plot(x, results["B"]["model_norm"], color="steelblue", linestyle="--",
            label=f"modello RPV - assegnazione B (r={results['B']['r_residual']:.3f})")
    ax.set_xlabel("Colonna (direzione across-track)")
    ax.set_ylabel("Valore normalizzato (media=1)")
    ax.set_title("Residuo empirico (dopo rimozione vignettatura) vs modello BRDF (RPV)")
    ax.legend(fontsize=8)
    ax.grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig(out_dir / "residual_vs_brdf_model.png", dpi=150)
    plt.close(fig)

    # --- Figura 2: profilo grezzo vs modello combinato (vignettatura x BRDF) ---
    fig, ax = plt.subplots(figsize=(10, 5.5))
    ax.plot(x, empirical_norm, color="black", linewidth=1.8, label="profilo empirico grezzo (normalizzato)")
    ax.plot(x, vignetting_shape_norm, color="gray", linestyle=":", label="sola componente vignettatura")
    ax.plot(x, results[best_label]["combined_norm"], color="darkorange", linewidth=2,
            label=f"modello combinato vignettatura x BRDF (assegn. {best_label}, r={results[best_label]['r_combined']:.3f})")
    ax.set_xlabel("Colonna (direzione across-track)")
    ax.set_ylabel("Valore normalizzato (media=1)")
    ax.set_title("Profilo grezzo vs modello combinato (vignettatura ottica x effetto BRDF hotspot)")
    ax.legend(fontsize=8)
    ax.grid(alpha=0.3)
    fig.tight_layout()
    fig.savefig(out_dir / "raw_vs_combined_model.png", dpi=150)
    plt.close(fig)

    print(f"\nOutput salvati in: {out_dir.resolve()}")
    print(" - report.txt")
    print(" - residual_vs_brdf_model.png")
    print(" - raw_vs_combined_model.png")


def main():
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("image", help="Immagine true-color (PNG/JPEG/TIFF)")
    parser.add_argument("--out", default="out_brdf", help="Cartella di output")
    parser.add_argument("--gps-csv", default=None, help="File CSV GPS per stimare l'azimuth di volo automaticamente")
    parser.add_argument("--flight-azimuth", type=float, default=None, help="Azimuth di volo manuale (gradi), alternativa a --gps-csv")
    parser.add_argument("--sun-azimuth", type=float, required=True, help="Azimuth solare (gradi da nord)")
    parser.add_argument("--sun-elevation", type=float, required=True, help="Elevazione solare sopra l'orizzonte (gradi)")
    parser.add_argument("--fov", type=float, default=24.75,
                         help="FOV across-track totale del sensore (gradi). Default 24.75 deg, "
                              "stimato da datasheet FS-60 (IFOV 0.9 mrad x 480 samples)")
    parser.add_argument("--vignette-json", default=None, help="File JSON di calibrazione vignettatura vendor (opzionale)")
    parser.add_argument("--band-key", type=int, default=None, help="Key della banda da usare dal file vignette-json")
    parser.add_argument("--rpv-k", type=float, default=1.0, help="Parametro RPV k (forma bowl/dome, default 1.0=neutro)")
    parser.add_argument("--rpv-theta", type=float, default=-0.15, help="Parametro RPV Theta (asimmetria fase, default -0.15)")
    parser.add_argument("--rpv-h", type=float, default=0.1, help="Parametro RPV h (nitidezza hotspot, default 0.1)")
    parser.add_argument("--nodata-thresh", type=float, default=3.0, help="Soglia luma per nodata (default 3.0)")
    args = parser.parse_args()

    analyze(args.image, args.out, args.sun_azimuth, args.sun_elevation, args.fov,
            flight_azimuth=args.flight_azimuth, gps_csv=args.gps_csv,
            vignette_json=args.vignette_json, band_key=args.band_key,
            rpv_k=args.rpv_k, rpv_theta=args.rpv_theta, rpv_h=args.rpv_h,
            nodata_thresh=args.nodata_thresh)


if __name__ == "__main__":
    main()

 

 

 

BRDF FigSpec FS60-C

Visto che nel volo ad Empoli era emersa l'importanza della BRDF nei dati acquisiti ho voluto provare con un volo precedente Si tratta di...