deha_hf / methods /method_b.py
hkayabilisim's picture
Initial commit
ab4021e
Raw
History Blame Contribute Delete
8.05 kB
"""
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