99from spatialdata import get_element_annotators
1010from spatialdata .models import PointsModel
1111import dask .dataframe as dd
12- import zarr
1312
1413
1514MORTON_CODE_NUM_BITS = 32 # Resulting morton codes will be stored as uint32.
1615MORTON_CODE_VALUE_MIN = 0
1716MORTON_CODE_VALUE_MAX = 2 ** (MORTON_CODE_NUM_BITS / 2 ) - 1
1817
18+ # In the first few rows (up to four), we set the morton code
19+ # value to a sentinel value for the rows that contain extrema,
20+ # x_min, x_max, y_min, and/or y_max data extent.
21+ # It is possible for the same row to contain extrema along more than one axis,
22+ # in which case we will only use fewer than four rows for the extrema.
23+ MORTON_CODE_EXTREME_VALUE_INDICATOR = 0
24+
1925# --------------------------
2026# Functions for computing Morton codes for SpatialData points (2D).
2127# --------------------------
@@ -42,16 +48,6 @@ def norm_ddf_to_uint(ddf):
4248 [x_min , x_max , y_min , y_max ] = [ddf ["x" ].min ().compute (), ddf ["x" ].max ().compute (), ddf ["y" ].min ().compute (), ddf ["y" ].max ().compute ()]
4349 ddf ["x_uint" ] = norm_series_to_uint (ddf ["x" ], x_min , x_max )
4450 ddf ["y_uint" ] = norm_series_to_uint (ddf ["y" ], y_min , y_max )
45-
46- # Insert the bounding box as metadata for the sdata.points[element] Points element dataframe.
47- # TODO: does anything special need to be done to ensure this is saved to disk?
48- ddf .attrs ["bounding_box" ] = {
49- "x_min" : float (x_min ),
50- "x_max" : float (x_max ),
51- "y_min" : float (y_min ),
52- "y_max" : float (y_max ),
53- }
54-
5551 return ddf
5652
5753
@@ -140,24 +136,57 @@ def morton_interleave(ddf):
140136def sdata_morton_sort_points (sdata , element ):
141137 ddf = sdata .points [element ]
142138
143- # Compute morton codes
139+ # Add normalized integer columns and morton codes using the full dataset bounding box.
144140 ddf = norm_ddf_to_uint (ddf )
145141 ddf ["morton_code_2d" ] = morton_interleave (ddf )
146142
143+ # Identify the extreme coordinate values.
144+ x_min_val = ddf ["x" ].min ().compute ()
145+ x_max_val = ddf ["x" ].max ().compute ()
146+ y_min_val = ddf ["y" ].min ().compute ()
147+ y_max_val = ddf ["y" ].max ().compute ()
148+
149+ # Build a boolean mask for the extreme rows using coordinate value equality.
150+ is_extreme = (
151+ (ddf ["x" ] == x_min_val ) | (ddf ["x" ] == x_max_val ) |
152+ (ddf ["y" ] == y_min_val ) | (ddf ["y" ] == y_max_val )
153+ )
154+
155+ extreme_pdf = ddf .loc [is_extreme ].compute ().reset_index (drop = True )
156+
157+ # Checking based on the row indices would be cleaner, but dask does not
158+ # guarantee unique indices across partitions.
159+ # Reference: https://docs.dask.org/en/stable/generated/dask.dataframe.DataFrame.reset_index.html
160+ # Instead, our `is_extreme` uses the min/max values themselves to identify the matching rows,
161+ # which can potentially return more than four rows if multiple rows match these extreme values.
162+ # TODO: in that case, reorder the extreme rows so the first four form the bounding box,
163+ # and any additional rows should be moved into `rest_df` before the morton-based sorting occurs.
164+ assert extreme_pdf .shape [0 ] <= 4
165+
166+ # Mark sentinel rows with morton_code_2d = MORTON_CODE_EXTREME_VALUE_INDICATOR so the reader can identify them without
167+ # relying on a hard-coded count or fixed row positions.
168+ extreme_pdf ["morton_code_2d" ] = np .uint32 (MORTON_CODE_EXTREME_VALUE_INDICATOR )
169+
170+ # Exclude the extreme rows from the rest.
171+ rest_ddf = ddf .loc [~ is_extreme ]
172+
147173 if "z" in ddf .columns :
148174 num_unique_z = ddf ["z" ].unique ().shape [0 ].compute ()
149175 if num_unique_z < 100 :
150176 # Heuristic for interpreting the 3D data as 2.5D
151177 # Reference: https://github.com/scverse/spatialdata/issues/961
152- sorted_ddf = ddf .sort_values (by = ["z" , "morton_code_2d" ], ascending = True )
178+ sorted_rest = rest_ddf .sort_values (by = ["z" , "morton_code_2d" ], ascending = True )
153179 else :
154180 # TODO: include z as a dimension in the morton code in the 3D case?
155-
156- # For now, just return the data sorted by 2D code.
157- sorted_ddf = ddf .sort_values (by = "morton_code_2d" , ascending = True )
181+ sorted_rest = rest_ddf .sort_values (by = "morton_code_2d" , ascending = True )
158182 else :
159- sorted_ddf = ddf .sort_values (by = "morton_code_2d" , ascending = True )
160- sdata .points [element ] = PointsModel .parse (sorted_ddf )
183+ sorted_rest = rest_ddf .sort_values (by = "morton_code_2d" , ascending = True )
184+
185+ # Prepend the sentinel rows (morton_code_2d == MORTON_CODE_EXTREME_VALUE_INDICATOR) before the morton-sorted data.
186+ sentinel_ddf = dd .from_pandas (extreme_pdf , npartitions = 1 )
187+ result_ddf = dd .concat ([sentinel_ddf , sorted_rest ], ignore_index = True , interleave_partitions = True )
188+
189+ sdata .points [element ] = PointsModel .parse (result_ddf )
161190
162191 # annotating_tables = get_element_annotators(sdata, element)
163192
@@ -175,15 +204,14 @@ def sdata_morton_query_rect_aux(sdata, element, orig_rect):
175204
176205 sorted_ddf = sdata .points [element ]
177206
178- # TODO: fail if no morton_code_2d column
179- # TODO: fail if not sorted as expected
180- # TODO: fail if no bounding box metadata
181-
182- bounding_box = sorted_ddf .attrs ["bounding_box" ]
183- x_min = bounding_box ["x_min" ]
184- x_max = bounding_box ["x_max" ]
185- y_min = bounding_box ["y_min" ]
186- y_max = bounding_box ["y_max" ]
207+ # Sentinel rows are identified by morton_code_2d == 0 and always precede the z-order data.
208+ # There are at most 4 (one per axis extreme), fewer when a single point covers multiple extremes.
209+ head = sorted_ddf .head (4 )
210+ sentinel_rows = head [head ["morton_code_2d" ] == MORTON_CODE_EXTREME_VALUE_INDICATOR ]
211+ x_min = float (sentinel_rows ["x" ].min ())
212+ x_max = float (sentinel_rows ["x" ].max ())
213+ y_min = float (sentinel_rows ["y" ].min ())
214+ y_max = float (sentinel_rows ["y" ].max ())
187215
188216 norm_rect = [
189217 orig_coord_to_norm_coord (orig_rect [0 ], orig_x_min = x_min , orig_x_max = x_max , orig_y_min = y_min , orig_y_max = y_max ),
@@ -210,13 +238,24 @@ def sdata_morton_query_rect(sdata, element, orig_rect):
210238
211239 morton_intervals = sdata_morton_query_rect_aux (sdata , element , orig_rect )
212240
213- # Get morton code column as a list of integers
241+ # Get morton code column as a list of integers.
214242 morton_sorted = sorted_ddf ["morton_code_2d" ].compute ().values .tolist ()
215243
244+ # Count leading sentinel rows (morton_code_2d == 0); stop at the first non-zero code.
245+ sentinel_count = 0
246+ for code in morton_sorted [:4 ]:
247+ if code == MORTON_CODE_EXTREME_VALUE_INDICATOR :
248+ sentinel_count += 1
249+ else :
250+ break
251+
216252 # Get a list of row ranges that match the morton intervals.
217253 # (This uses binary searches internally to find the matching row indices).
218254 # [ (row_start, row_end), ... ]
219- matching_row_ranges = zquery_rows (morton_sorted , morton_intervals , merge = True )
255+ matching_row_ranges = zquery_rows (morton_sorted [sentinel_count :], morton_intervals , merge = True )
256+
257+ # Offset ranges to account for the sentinel rows at the start of the table.
258+ matching_row_ranges = [(i + sentinel_count , j + sentinel_count ) for i , j in matching_row_ranges ]
220259
221260 return matching_row_ranges
222261
@@ -228,7 +267,14 @@ def sdata_morton_query_rect_debug(sdata, element, orig_rect):
228267 sorted_ddf = sdata .points [element ]
229268 morton_intervals = sdata_morton_query_rect_aux (sdata , element , orig_rect )
230269 morton_sorted = sorted_ddf ["morton_code_2d" ].compute ().values .tolist ()
231- matching_row_ranges , rows_checked = zquery_rows_aux (morton_sorted , morton_intervals , merge = True )
270+ sentinel_count = 0
271+ for code in morton_sorted [:4 ]:
272+ if code == MORTON_CODE_EXTREME_VALUE_INDICATOR :
273+ sentinel_count += 1
274+ else :
275+ break
276+ matching_row_ranges , rows_checked = zquery_rows_aux (morton_sorted [sentinel_count :], morton_intervals , merge = True )
277+ matching_row_ranges = [(i + sentinel_count , j + sentinel_count ) for i , j in matching_row_ranges ]
232278 return matching_row_ranges , rows_checked
233279
234280# --------------------------
@@ -470,27 +516,6 @@ def try_index(gene_name):
470516 return ddf
471517
472518
473- def sdata_points_write_bounding_box_attrs (sdata , element ) -> dd .DataFrame :
474- ddf = sdata .points [element ]
475-
476- [x_min , x_max , y_min , y_max ] = [ddf ["x" ].min ().compute (), ddf ["x" ].max ().compute (), ddf ["y" ].min ().compute (), ddf ["y" ].max ().compute ()]
477- bounding_box = {
478- "x_min" : float (x_min ),
479- "x_max" : float (x_max ),
480- "y_min" : float (y_min ),
481- "y_max" : float (y_max ),
482- }
483-
484- sdata_path = sdata .path
485- # TODO: error if no path
486-
487- # Insert the bounding box as metadata for the sdata.points[element] Points element dataframe.
488- z = zarr .open (sdata_path , mode = 'a' )
489- group = z [f'points/{ element } ' ]
490- group .attrs ['bounding_box' ] = bounding_box
491-
492- # TODO: does anything special need to be done to ensure this is saved to disk?
493-
494519
495520def sdata_points_modify_row_group_size (sdata , element , row_group_size : int = 50_000 ):
496521 import pyarrow .parquet as pq
0 commit comments