Spaces:
Sleeping
Sleeping
| """ | |
| Method RAST — Raster-only observability scoring. | |
| ================================================= | |
| Uses only the point geometries and the raster pixel values; no attribute | |
| fields are required. | |
| For each pole point: | |
| 1. An *inner circle* (radius = ``inner_radius``) is drawn around the pole. | |
| The maximum pixel value inside this circle is ``l_pole``. | |
| 2. An *annulus* (ring between ``inner_radius`` and ``outer_radius``) is | |
| drawn around the pole. The maximum pixel value inside the annulus is | |
| ``l_bg``. | |
| 3. Global background statistics are computed across **all** poles: | |
| mu_bg = median(l_bg) | |
| sigma_bg = MAD(l_bg) | |
| 4. Per-pole observability score: | |
| s = (l_pole - mu_bg) / sigma_bg | |
| 5. Detection: detected = (s >= threshold) | |
| Produced attributes | |
| ------------------- | |
| l_pole, l_bg, mu_bg, sigma_bg, s, | |
| img_radiance_near_mean, img_radiance_near_minus_far, | |
| method, detected | |
| """ | |
| import math | |
| import numpy as np | |
| import geopandas as gpd | |
| import rasterio | |
| from rasterio.mask import mask as rasterio_mask | |
| from scipy.stats import median_abs_deviation | |
| def run(tiff_path, vector_path, band=1, threshold=3.0, | |
| inner_radius=5, outer_radius=15): | |
| """ | |
| Run the RAST 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, GeoJSON / GPKG / SHP). | |
| band : int | |
| Raster band to use (1, 2 or 3). | |
| threshold : float | |
| Observability threshold. A point is *detected* when ``s >= threshold``. | |
| inner_radius : int | |
| Inner radius of the annulus in metres (also the pole-circle radius). | |
| outer_radius : int | |
| Outer radius of the annulus in metres. | |
| Returns | |
| ------- | |
| geopandas.GeoDataFrame | |
| Original poles enriched with RAST attributes. | |
| """ | |
| # ------------------------------------------------------------------ | |
| # Read vector data | |
| # ------------------------------------------------------------------ | |
| gdf = gpd.read_file(vector_path) | |
| # Convert any datetime columns to ISO strings (avoids JSON issues) | |
| 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 | |
| # Reproject vector to raster CRS | |
| gdf_raster_crs = gdf.to_crs(raster_crs) | |
| # Determine a projected CRS (UTM) for metre-accurate buffering | |
| 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 pixel extraction | |
| # ------------------------------------------------------------------ | |
| l_pole_list = [] | |
| l_bg_list = [] | |
| near_mean_list = [] | |
| near_minus_far_list = [] | |
| for _idx, row in gdf_proj.iterrows(): | |
| point = row.geometry | |
| inner_circle = point.buffer(inner_radius) | |
| outer_circle = point.buffer(outer_radius) | |
| annulus = outer_circle.difference(inner_circle) | |
| # Reproject geometries back to raster CRS for masking | |
| inner_gdf = gpd.GeoDataFrame( | |
| geometry=[inner_circle], crs=utm_crs | |
| ).to_crs(raster_crs) | |
| annulus_gdf = gpd.GeoDataFrame( | |
| geometry=[annulus], crs=utm_crs | |
| ).to_crs(raster_crs) | |
| inner_geom = [inner_gdf.geometry.iloc[0]] | |
| annulus_geom = [annulus_gdf.geometry.iloc[0]] | |
| # Extract pixels inside inner circle | |
| try: | |
| inner_data, _ = rasterio_mask( | |
| src, inner_geom, crop=True, nodata=0, filled=True | |
| ) | |
| band_data_inner = inner_data[band - 1] | |
| valid_inner = band_data_inner[band_data_inner != 0] | |
| l_pole = float(np.max(valid_inner)) if len(valid_inner) > 0 else 0.0 | |
| near_mean = float(np.mean(valid_inner)) if len(valid_inner) > 0 else 0.0 | |
| except Exception: | |
| l_pole = 0.0 | |
| near_mean = 0.0 | |
| # Extract pixels inside annulus | |
| try: | |
| annulus_data, _ = rasterio_mask( | |
| src, annulus_geom, crop=True, nodata=0, filled=True | |
| ) | |
| band_data_annulus = annulus_data[band - 1] | |
| valid_annulus = band_data_annulus[band_data_annulus != 0] | |
| l_bg = float(np.max(valid_annulus)) if len(valid_annulus) > 0 else 0.0 | |
| far_mean = float(np.mean(valid_annulus)) if len(valid_annulus) > 0 else 0.0 | |
| except Exception: | |
| l_bg = 0.0 | |
| far_mean = 0.0 | |
| l_pole_list.append(l_pole) | |
| l_bg_list.append(l_bg) | |
| near_mean_list.append(near_mean) | |
| near_minus_far_list.append(near_mean - far_mean) | |
| # ------------------------------------------------------------------ | |
| # Global statistics | |
| # ------------------------------------------------------------------ | |
| l_bg_array = np.array(l_bg_list) | |
| mu_bg = float(np.median(l_bg_array)) | |
| sigma_bg = float(median_abs_deviation(l_bg_array)) | |
| # ------------------------------------------------------------------ | |
| # Per-point scoring | |
| # ------------------------------------------------------------------ | |
| s_list = [] | |
| for l_pole in l_pole_list: | |
| if sigma_bg > 0: | |
| s = (l_pole - mu_bg) / sigma_bg | |
| else: | |
| s = 0.0 | |
| s_list.append(s) | |
| # ------------------------------------------------------------------ | |
| # Build output GeoDataFrame | |
| # ------------------------------------------------------------------ | |
| result_gdf = gdf.copy() | |
| result_gdf['l_pole'] = l_pole_list | |
| result_gdf['l_bg'] = l_bg_list | |
| result_gdf['mu_bg'] = mu_bg | |
| result_gdf['sigma_bg'] = sigma_bg | |
| result_gdf['s'] = s_list | |
| result_gdf['img_radiance_near_mean'] = near_mean_list | |
| result_gdf['img_radiance_near_minus_far'] = near_minus_far_list | |
| result_gdf['method'] = 'RAST' | |
| result_gdf['detected'] = [bool(s >= threshold) for s in s_list] | |
| return result_gdf | |