1- # -*- coding: utf-8 -*-
2- """Optimized raster_to_nodes function without rasterstats, using rasterio directly."""
3-
41import numpy as np
5- import rasterio
6- from rasterio .mask import mask
7- from shapely .geometry import Polygon , mapping
82import itertools
3+ import rasterio
4+ from rasterio .features import rasterize
5+ from rasterio .windows import from_bounds
6+ from shapely .geometry import Polygon , box
7+
8+ # ---------- helpers (minimal) ----------
9+
10+ def _union_missing_mask (ma , extra_mask_values ):
11+ """Union raster mask with explicit sentinels (e.g., 0.0, -9999)."""
12+ if extra_mask_values is None :
13+ return ma
14+ if not isinstance (extra_mask_values , (list , tuple , np .ndarray )):
15+ extra_mask_values = [extra_mask_values ]
16+ data = ma .data
17+ add_mask = np .zeros (data .shape , dtype = bool )
18+ for mv in extra_mask_values :
19+ add_mask |= (data == mv )
20+ return np .ma .array (data , mask = (np .asarray (ma .mask , bool ) | add_mask ), copy = False )
21+
22+ def _elements_bounds_in_raster (elements_polys , raster_bounds ):
23+ """BBox of union(elements ∩ raster) or None if empty."""
24+ rb = box (* raster_bounds )
25+ any_hit = False
26+ xmin , ymin , xmax , ymax = + np .inf , + np .inf , - np .inf , - np .inf
27+ for poly in elements_polys :
28+ if poly .is_empty :
29+ continue
30+ inter = poly .intersection (rb )
31+ if inter .is_empty :
32+ continue
33+ bxmin , bymin , bxmax , bymax = inter .bounds
34+ xmin , ymin = min (xmin , bxmin ), min (ymin , bymin )
35+ xmax , ymax = max (xmax , bxmax ), max (ymax , bymax )
36+ any_hit = True
37+ return (xmin , ymin , xmax , ymax ) if any_hit else None
938
39+ # ---------- main (rasterize-only) ----------
1040
1141def raster_to_nodes (
1242 mesh ,
@@ -17,116 +47,192 @@ def raster_to_nodes(
1747 mask_value = None ,
1848 fill_value = None ,
1949 band = 1 ,
50+ missing_policy = "exclude" ,
51+ rasterize_all_touched = True ,
2052):
2153 """
22- Applies raster values to mesh by calculating means over surrounding elements.
54+ Map raster values onto mesh nodes by averaging values over surrounding elements.
55+
56+ This function samples a raster onto an unstructured mesh. Each element polygon
57+ is intersected with the raster grid, values are averaged per element, and then
58+ element values are aggregated to nodes using an area-weighted mean.
2359
2460 Parameters
2561 ----------
26- (same as original)
62+ mesh : object
63+ Mesh object with the following interface:
64+ - ``mesh.nodes`` : array of node coordinates (N, >=2), x and y in the first two columns.
65+ - ``mesh.elem(i)`` : return node indices for element ``i``.
66+ - ``mesh.get_elems_i_from_node(node_i)`` : return element indices connected to node ``node_i``.
67+ - ``mesh.areas()`` : array of element areas indexed by element id.
68+ nodes_sel : sequence of int
69+ Node indices at which values will be computed.
70+ path_raster : str
71+ Path to a raster file (e.g., GeoTIFF) readable by rasterio.
72+ bins : sequence of float, optional
73+ Bin edges used to classify raster values. If provided, the raster values
74+ are digitized into bins before aggregation. Length must be ``M``.
75+ mapped_values : sequence of float, optional
76+ Values assigned to each bin index. Must have length ``M+1`` if ``bins`` is provided.
77+ Typically used to map classes into the interval [0, 1].
78+ mask_value : scalar or sequence of scalars, optional
79+ Additional raster values to treat as missing (in addition to raster
80+ NoData). Example: ``[0.0, -9999]``.
81+ fill_value : float, optional
82+ Default value for elements with no valid raster pixels in the continuous
83+ (non-classified) case. If omitted, defaults to 0.0. Ignored when
84+ classification is active.
85+ band : int, default=1
86+ 1-based band index in the raster to sample.
87+ missing_policy : {"exclude", "as_zero"}, default="exclude"
88+ Policy for handling missing pixels in the classified case:
89+
90+ - ``"exclude"`` : compute class averages using only valid pixels. Elements
91+ with no valid pixels receive the default class value (0.0).
92+ - ``"as_zero"`` : missing pixels count as class 0 by including them in the
93+ denominator. This dilutes averages toward 0 when coverage is sparse.
94+
95+ Has no effect in the continuous (non-classified) case.
96+ rasterize_all_touched : bool, default=True
97+ If True, count all raster cells touched by element polygons. If False,
98+ count only cells whose centers fall within the polygons.
2799
28100 Returns
29101 -------
30- numpy.array
31- means of raster values of the element balls around the nodes
102+ ndarray of float
103+ Array of values at each node in ``nodes_sel``. Length matches ``len(nodes_sel)``.
104+
105+ Notes
106+ -----
107+ - Continuous mode (no ``bins``): averages raster values directly.
108+ - Classified mode (with ``bins`` and ``mapped_values``): raster values are
109+ digitized into bins, mapped to user-provided class values, then averaged.
110+ - All aggregation is area-weighted: element values are computed from raster
111+ pixels, and node values are weighted by connected element areas.
32112 """
33113
34- # Step 1: Prepare binning if needed
35- classify_raster = False
36- if bins is not None :
114+ classify = bins is not None
115+ if classify :
37116 if mapped_values is None :
38117 raise ValueError ("mapped_values must be provided if bins are used." )
39118 if len (mapped_values ) != len (bins ) + 1 :
40119 raise ValueError ("mapped_values must be one longer than bins." )
41- classify_raster = True
120+ if missing_policy not in ("exclude" , "as_zero" ):
121+ raise ValueError ("missing_policy must be 'exclude' or 'as_zero'." )
42122
43- # Step 2: Precompute node balls (cache)
44- elements_in_balls = {
45- node_i : list (mesh .get_elems_i_from_node (node_i )) for node_i in nodes_sel
46- }
47- elements_in_polygon = sorted (
48- set (itertools .chain .from_iterable (elements_in_balls .values ()))
49- )
123+ # Node -> elements and unique element ids
124+ elements_in_balls = {n : list (mesh .get_elems_i_from_node (n )) for n in nodes_sel }
125+ elements_all = sorted (set (itertools .chain .from_iterable (elements_in_balls .values ())))
50126
51- # Step 3: Build element polygons
127+ # Build element polygons (XY only)
52128 element_polygons = {}
53- for elem_i in elements_in_polygon :
129+ for elem_i in elements_all :
54130 nodes_idx = mesh .elem (elem_i )
55- coords = mesh .nodes [nodes_idx , :2 ] # x,y only
131+ coords = mesh .nodes [nodes_idx , :2 ]
56132 element_polygons [elem_i ] = Polygon (coords )
57133
58- # Step 4: Open raster
134+ # Defaults per path
135+ elem_default_cont = (fill_value if fill_value is not None else 0.0 )
136+ elem_default_class = 0.0
137+
59138 with rasterio .open (path_raster ) as src :
60- nodata = src .nodatavals [band - 1 ] if src .nodatavals else None
61-
62- # Read full raster band if binning is needed
63- if classify_raster :
64- raster_array = src .read (band )
65- if mask_value is not None :
66- raster_array = np .where (
67- raster_array == mask_value , fill_value , raster_array
68- )
69- digitized = np .digitize (raster_array , bins , right = False )
70- classified_array = np .array (mapped_values )[digitized ]
71- else :
72- classified_array = None # Will read on demand
73-
74- # Step 5: Calculate mean per element
75- sav_in_elements = {}
76- for elem_i , polygon in element_polygons .items ():
77- geom = [mapping (polygon )]
78- if classify_raster is False :
79- out_image , out_transform = mask (
80- src , geom , crop = True , indexes = band , nodata = nodata
81- )
82- data = out_image [0 ]
139+ # Minimal window that covers mesh ∩ raster
140+ bbox = _elements_bounds_in_raster (list (element_polygons .values ()), src .bounds )
141+ # If nothing overlaps: return defaults
142+ if bbox is None :
143+ default_val = elem_default_class if classify else elem_default_cont
144+ return np .full (len (nodes_sel ), default_val , dtype = float )
145+
146+ win = from_bounds (* bbox , transform = src .transform )
147+ full_win = from_bounds (* src .bounds , transform = src .transform )
148+ win = win .intersection (full_win )
149+
150+ # Read data as masked array over that window
151+ data = src .read (band , window = win , masked = True ) # (H,W) masked
152+ if data .ndim == 3 : # (1,H,W) -> (H,W) if env returns 3D
153+ data = data [0 ]
154+ data = _union_missing_mask (data , mask_value )
155+
156+ # Rasterize element IDs on same window grid
157+ window_transform = rasterio .windows .transform (win , src .transform )
158+ shapes = [(poly , int (eid )) for eid , poly in element_polygons .items ()]
159+ labels = rasterize (
160+ shapes ,
161+ out_shape = data .shape ,
162+ transform = window_transform ,
163+ fill = 0 , # background label
164+ dtype = "int32" ,
165+ all_touched = rasterize_all_touched ,
166+ )
167+
168+ # Prepare result per-element
169+ elem_values = {eid : (elem_default_class if classify else elem_default_cont )
170+ for eid in element_polygons .keys ()}
171+
172+ # VALID pixels only
173+ valid = ~ data .mask
174+ labs_valid = labels [valid ]
175+ vals_valid = data .data [valid ]
176+ sel_valid = labs_valid != 0
177+
178+ if classify :
179+ # If there are valid class pixels, aggregate them
180+ if sel_valid .any ():
181+ bins_arr = np .asarray (bins )
182+ mapped = np .asarray (mapped_values )
183+ idx = np .digitize (vals_valid [sel_valid ], bins_arr , right = False ) # 0..len(bins)
184+ class_vals = mapped [idx ]
185+
186+ max_id = int (labels .max ()) if labels .size else 0
187+ sums_valid = np .bincount (labs_valid [sel_valid ], weights = class_vals , minlength = max_id + 1 )
188+ cnts_valid = np .bincount (labs_valid [sel_valid ], minlength = max_id + 1 )
83189 else :
84- # Clip manually from classified_array
85- bounds = polygon .bounds # (minx, miny, maxx, maxy)
86- row_min , col_min = src .index (bounds [0 ], bounds [3 ]) # (xmin, ymax)
87- row_max , col_max = src .index (bounds [2 ], bounds [1 ]) # (xmax, ymin)
88- rows = slice (min (row_min , row_max ), max (row_min , row_max ) + 1 )
89- cols = slice (min (col_min , col_max ), max (col_min , col_max ) + 1 )
90- if rows .start == rows .stop or cols .start == cols .stop :
91- sav_in_elements [elem_i ] = 0.0
92- continue
93- window = classified_array [rows , cols ]
94- # Verify that window has nonzero size
95- if window .shape [0 ] == 0 or window .shape [1 ] == 0 :
96- sav_in_elements [elem_i ] = 0.0
97- continue
98- # Build temporary profile
99- transform = src .window_transform (
100- ((rows .start , rows .stop ), (cols .start , cols .stop ))
101- )
102- with rasterio .io .MemoryFile () as memfile :
103- with memfile .open (
104- driver = "GTiff" ,
105- height = window .shape [0 ],
106- width = window .shape [1 ],
107- count = 1 ,
108- dtype = window .dtype ,
109- transform = transform ,
110- crs = src .crs ,
111- nodata = - 999 ,
112- ) as dataset :
113- dataset .write (window , 1 )
114- out_image , out_transform = mask (
115- dataset , geom , crop = True , indexes = 1 , nodata = - 999
116- )
117- data = out_image [0 ]
118-
119- masked = np .ma .masked_array (data , mask = (data == nodata ))
120- mean_val = masked .mean () if masked .count () > 0 else 0.0
121- sav_in_elements [elem_i ] = mean_val
122-
123- # Step 6: Assemble node values
190+ max_id = int (labels .max ()) if labels .size else 0
191+ sums_valid = np .zeros (max_id + 1 , dtype = float )
192+ cnts_valid = np .zeros (max_id + 1 , dtype = float )
193+
194+ if missing_policy == "as_zero" :
195+ # Denominator: all pixels of element (valid+masked), excluding background
196+ labs_all = labels .ravel ()
197+ sel_all = labs_all != 0
198+ cnts_total = np .bincount (labs_all [sel_all ], minlength = max_id + 1 )
199+ present = np .nonzero (cnts_total )[0 ]
200+ for eid in present :
201+ denom = cnts_total [eid ]
202+ num = sums_valid [eid ] # masked contribute 0
203+ elem_values [eid ] = float (num / denom ) if denom > 0 else elem_default_class
204+ else :
205+ # "exclude": denominator is count of valid only
206+ present = np .nonzero (cnts_valid )[0 ]
207+ for eid in present :
208+ denom = cnts_valid [eid ]
209+ elem_values [eid ] = float (sums_valid [eid ] / denom ) if denom > 0 else elem_default_class
210+
211+ else :
212+ # Continuous: mean over valid pixels per element
213+ if sel_valid .any ():
214+ max_id = int (labels .max ()) if labels .size else 0
215+ sums = np .bincount (labs_valid [sel_valid ], weights = vals_valid [sel_valid ], minlength = max_id + 1 )
216+ cnts = np .bincount (labs_valid [sel_valid ], minlength = max_id + 1 )
217+ present = np .nonzero (cnts )[0 ]
218+ for eid in present :
219+ elem_values [eid ] = float (sums [eid ] / cnts [eid ]) if cnts [eid ] > 0 else elem_default_cont
220+ # else: keep defaults (no valid pixels anywhere)
221+
222+ # Area-weighted average from elements to nodes
124223 elem_areas = mesh .areas ()
125- sav_at_nodes = np .empty ((len (nodes_sel ),), dtype = float )
224+ out_vals = np .empty ((len (nodes_sel ),), dtype = float )
126225 for i , node_i in enumerate (nodes_sel ):
127226 ball = elements_in_balls [node_i ]
128- values = [sav_in_elements [e ] for e in ball ]
129- weights = elem_areas [ball ]
130- sav_at_nodes [i ] = np .average (values , weights = weights )
227+ if not ball :
228+ out_vals [i ] = 0.0
229+ continue
230+ values = np .array ([elem_values [e ] for e in ball ], dtype = float )
231+ weights = np .array (elem_areas [ball ], dtype = float )
232+ bad = ~ np .isfinite (values )
233+ if bad .any ():
234+ weights = weights .copy (); weights [bad ] = 0.0
235+ values = np .where (bad , 0.0 , values )
236+ out_vals [i ] = np .average (values , weights = weights ) if weights .sum () > 0 else 0.0
131237
132- return sav_at_nodes
238+ return out_vals
0 commit comments