Spaces:
Sleeping
Sleeping
| """ | |
| Method B — Per-pole robust Z-score detection. | |
| ============================================== | |
| Ported from the R prototype ``program.R``. | |
| For each pole point: | |
| 1. **Pole sampling window** — a circle of radius ``pole_window_m / 2`` | |
| around the pole. Three radiance statistics are extracted: | |
| I_pole_max = max(pole pixels) | |
| I_pole_median = median(pole pixels) | |
| I_pole_Q95 = 95th percentile(pole pixels) | |
| 2. **Background ring (annulus)** — between ``bg_inner_m`` and | |
| ``bg_outer_m``. Two statistics are extracted: | |
| I_bg_median = median(background pixels) | |
| MAD_bg = median(|bg - I_bg_median|) | |
| 3. **Robust Z-scores** (per-pole, using the *local* background): | |
| sigma_robust = max(1.4826 * MAD_bg, eps) | |
| Z_max = (I_pole_max - I_bg_median) / sigma_robust | |
| Z_median = (I_pole_median - I_bg_median) / sigma_robust | |
| Z_Q95 = (I_pole_Q95 - I_bg_median) / sigma_robust | |
| 4. **Detection decisions** (Z >= T_z): | |
| det_max, det_median, det_Q95 | |
| Key difference from RAST / Method A | |
| ----------------------------------- | |
| Method B uses a **local** background ring per pole (not global statistics) | |
| and produces three detection metrics (max / median / Q95). | |
| Produced attributes | |
| ------------------- | |
| I_pole_max, I_pole_median, I_pole_Q95, I_bg_median, MAD_bg, | |
| Z_max, Z_median, Z_Q95, det_max, det_median, det_Q95, | |
| pole_window_m, bg_inner_m, bg_outer_m, T_z, method | |
| """ | |
| import math | |
| import numpy as np | |
| import geopandas as gpd | |
| import rasterio | |
| from rasterio.mask import mask as rasterio_mask | |
| def run(tiff_path, vector_path, band=1, threshold=3.0, | |
| pole_window_m=30.0, bg_inner_m=40.0, bg_outer_m=80.0): | |
| """ | |
| Run the Method B analysis. | |
| Parameters | |
| ---------- | |
| tiff_path : str | |
| Path to the input GeoTIFF (night-time light raster). | |
| vector_path : str | |
| Path to the input vector file (pole points). | |
| band : int | |
| Raster band to use (1, 2 or 3). | |
| threshold : float | |
| Robust Z-score threshold ``T_z``. A pole is *detected* when | |
| ``Z >= T_z``. | |
| pole_window_m : float | |
| Diameter of the pole sampling window in metres | |
| (buffer radius = ``pole_window_m / 2``). | |
| bg_inner_m : float | |
| Inner radius of the background ring in metres. | |
| bg_outer_m : float | |
| Outer radius of the background ring in metres. | |
| Returns | |
| ------- | |
| geopandas.GeoDataFrame | |
| Original poles enriched with Method B attributes. | |
| """ | |
| T_z = float(threshold) | |
| eps = 1e-6 | |
| pole_radius = pole_window_m / 2.0 | |
| # ------------------------------------------------------------------ | |
| # Read vector data | |
| # ------------------------------------------------------------------ | |
| gdf = gpd.read_file(vector_path) | |
| for col in gdf.columns: | |
| if str(gdf[col].dtype).startswith('datetime'): | |
| gdf[col] = gdf[col].apply( | |
| lambda x: x.isoformat() | |
| if x is not None and not (isinstance(x, float) and math.isnan(x)) | |
| else None | |
| ) | |
| # ------------------------------------------------------------------ | |
| # Open raster and reproject vector | |
| # ------------------------------------------------------------------ | |
| with rasterio.open(tiff_path) as src: | |
| raster_crs = src.crs | |
| gdf_raster_crs = gdf.to_crs(raster_crs) | |
| centroid = gdf_raster_crs.geometry.unary_union.centroid | |
| lon, lat = centroid.x, centroid.y | |
| if gdf_raster_crs.crs.is_geographic: | |
| utm_zone = int((lon + 180) / 6) + 1 | |
| hemisphere = 'north' if lat >= 0 else 'south' | |
| utm_crs = f'+proj=utm +zone={utm_zone} +{hemisphere} +datum=WGS84' | |
| else: | |
| utm_crs = raster_crs | |
| gdf_proj = gdf_raster_crs.to_crs(utm_crs) | |
| # ------------------------------------------------------------------ | |
| # Per-pole extraction | |
| # ------------------------------------------------------------------ | |
| n = len(gdf_proj) | |
| I_pole_max_arr = np.full(n, np.nan) | |
| I_pole_median_arr = np.full(n, np.nan) | |
| I_pole_Q95_arr = np.full(n, np.nan) | |
| I_bg_median_arr = np.full(n, np.nan) | |
| MAD_bg_arr = np.full(n, np.nan) | |
| Z_max_arr = np.full(n, np.nan) | |
| Z_median_arr = np.full(n, np.nan) | |
| Z_Q95_arr = np.full(n, np.nan) | |
| det_max_arr = np.zeros(n, dtype=int) | |
| det_median_arr = np.zeros(n, dtype=int) | |
| det_Q95_arr = np.zeros(n, dtype=int) | |
| for i, (_idx, row) in enumerate(gdf_proj.iterrows()): | |
| point = row.geometry | |
| # --- 6.1 Pole sampling window --- | |
| pole_circle = point.buffer(pole_radius) | |
| pole_gdf = gpd.GeoDataFrame( | |
| geometry=[pole_circle], crs=utm_crs | |
| ).to_crs(raster_crs) | |
| pole_geom = [pole_gdf.geometry.iloc[0]] | |
| try: | |
| pole_data, _ = rasterio_mask( | |
| src, pole_geom, crop=True, nodata=0, filled=True | |
| ) | |
| pole_band = pole_data[band - 1] | |
| pole_vals = pole_band[pole_band != 0] | |
| except Exception: | |
| pole_vals = np.array([]) | |
| if len(pole_vals) == 0: | |
| continue | |
| I_pole_max_arr[i] = float(np.max(pole_vals)) | |
| I_pole_median_arr[i] = float(np.median(pole_vals)) | |
| I_pole_Q95_arr[i] = float(np.percentile(pole_vals, 95)) | |
| # --- 6.2 Background ring (annulus) --- | |
| bg_outer_circle = point.buffer(bg_outer_m) | |
| bg_inner_circle = point.buffer(bg_inner_m) | |
| bg_ring = bg_outer_circle.difference(bg_inner_circle) | |
| bg_gdf = gpd.GeoDataFrame( | |
| geometry=[bg_ring], crs=utm_crs | |
| ).to_crs(raster_crs) | |
| bg_geom = [bg_gdf.geometry.iloc[0]] | |
| try: | |
| bg_data, _ = rasterio_mask( | |
| src, bg_geom, crop=True, nodata=0, filled=True | |
| ) | |
| bg_band = bg_data[band - 1] | |
| bg_vals = bg_band[bg_band != 0] | |
| except Exception: | |
| bg_vals = np.array([]) | |
| # Need at least 5 background pixels | |
| if len(bg_vals) < 5: | |
| continue | |
| bg_median = float(np.median(bg_vals)) | |
| bg_mad = float(np.median(np.abs(bg_vals - bg_median))) | |
| sigma_robust = max(1.4826 * bg_mad, eps) | |
| I_bg_median_arr[i] = bg_median | |
| MAD_bg_arr[i] = bg_mad | |
| # --- 6.3 Robust Z-scores --- | |
| Z_max_arr[i] = (I_pole_max_arr[i] - bg_median) / sigma_robust | |
| Z_median_arr[i] = (I_pole_median_arr[i] - bg_median) / sigma_robust | |
| Z_Q95_arr[i] = (I_pole_Q95_arr[i] - bg_median) / sigma_robust | |
| # --- 6.4 Detection decisions --- | |
| det_max_arr[i] = int(Z_max_arr[i] >= T_z) | |
| det_median_arr[i] = int(Z_median_arr[i] >= T_z) | |
| det_Q95_arr[i] = int(Z_Q95_arr[i] >= T_z) | |
| # ------------------------------------------------------------------ | |
| # Build output GeoDataFrame | |
| # ------------------------------------------------------------------ | |
| result_gdf = gdf.copy() | |
| result_gdf['I_pole_max'] = I_pole_max_arr | |
| result_gdf['I_pole_median'] = I_pole_median_arr | |
| result_gdf['I_pole_Q95'] = I_pole_Q95_arr | |
| result_gdf['I_bg_median'] = I_bg_median_arr | |
| result_gdf['MAD_bg'] = MAD_bg_arr | |
| result_gdf['Z_max'] = Z_max_arr | |
| result_gdf['Z_median'] = Z_median_arr | |
| result_gdf['Z_Q95'] = Z_Q95_arr | |
| result_gdf['det_max'] = det_max_arr | |
| result_gdf['det_median'] = det_median_arr | |
| result_gdf['det_Q95'] = det_Q95_arr | |
| result_gdf['pole_window_m'] = pole_window_m | |
| result_gdf['bg_inner_m'] = bg_inner_m | |
| result_gdf['bg_outer_m'] = bg_outer_m | |
| result_gdf['T_z'] = T_z | |
| result_gdf['method'] = 'Method B' | |
| return result_gdf | |