Skip to content

Commit d3e23e0

Browse files
committed
Remove storage of explicit bounding_box metadata
1 parent 4407ad4 commit d3e23e0

3 files changed

Lines changed: 98 additions & 60 deletions

File tree

src/vitessce/data_utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
sdata_morton_sort_points,
2323
# Other helper functions
2424
sdata_points_process_columns,
25-
sdata_points_write_bounding_box_attrs,
2625
sdata_points_modify_row_group_size,
2726
# Functions for querying
2827
sdata_morton_query_rect,
2928
row_ranges_to_row_indices,
29+
MORTON_CODE_EXTREME_VALUE_INDICATOR,
3030
)

src/vitessce/data_utils/spatialdata_points_zorder.py

Lines changed: 76 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,19 @@
99
from spatialdata import get_element_annotators
1010
from spatialdata.models import PointsModel
1111
import dask.dataframe as dd
12-
import zarr
1312

1413

1514
MORTON_CODE_NUM_BITS = 32 # Resulting morton codes will be stored as uint32.
1615
MORTON_CODE_VALUE_MIN = 0
1716
MORTON_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):
140136
def 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

495520
def sdata_points_modify_row_group_size(sdata, element, row_group_size: int = 50_000):
496521
import pyarrow.parquet as pq

tests/test_sdata_points_zorder.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
sdata_morton_query_rect_debug,
1010
row_ranges_to_row_indices,
1111
orig_coord_to_norm_coord,
12+
MORTON_CODE_EXTREME_VALUE_INDICATOR
1213
)
1314

1415

@@ -30,11 +31,22 @@ def test_zorder_sorting(sdata_with_points):
3031

3132
sdata_morton_sort_points(sdata, "transcripts")
3233

33-
# Check that the morton codes are sorted
3434
sorted_ddf = sdata.points["transcripts"]
3535
morton_sorted = sorted_ddf["morton_code_2d"].compute().values.tolist()
3636

37-
assert _is_sorted(morton_sorted)
37+
# Count leading sentinel rows, identified by morton_code_2d == MORTON_CODE_EXTREME_VALUE_INDICATOR
38+
sentinel_count = 0
39+
for code in morton_sorted[:4]:
40+
if code == MORTON_CODE_EXTREME_VALUE_INDICATOR:
41+
sentinel_count += 1
42+
else:
43+
break
44+
45+
assert sentinel_count >= 2 # at least the x_min and x_max extremes are distinct
46+
assert all(code == MORTON_CODE_EXTREME_VALUE_INDICATOR for code in morton_sorted[:sentinel_count])
47+
48+
# Z-order sorted data begins after the sentinel rows
49+
assert _is_sorted(morton_sorted[sentinel_count:])
3850

3951

4052
def test_zorder_query(sdata_with_points):
@@ -55,6 +67,13 @@ def test_zorder_query(sdata_with_points):
5567

5668
assert df.shape[0] == 213191
5769

70+
# Read bounding box from sentinel rows (morton_code_2d == 0) at the start of the table
71+
sentinel_rows = df.iloc[:4][df.iloc[:4]["morton_code_2d"] == MORTON_CODE_EXTREME_VALUE_INDICATOR]
72+
x_min = float(sentinel_rows["x"].min())
73+
x_max = float(sentinel_rows["x"].max())
74+
y_min = float(sentinel_rows["y"].min())
75+
y_max = float(sentinel_rows["y"].max())
76+
5877
# Do the same query the "dumb" way, by checking all points
5978

6079
# We need an epsilon for the "dumb" query since the normalization
@@ -86,12 +105,6 @@ def test_zorder_query(sdata_with_points):
86105

87106
# Do a second check, this time against x_uint/y_uint (the normalized coordinates)
88107
# TODO: does this ensure that estimated == exact?
89-
90-
bounding_box = ddf.attrs["bounding_box"]
91-
x_min = bounding_box["x_min"]
92-
x_max = bounding_box["x_max"]
93-
y_min = bounding_box["y_min"]
94-
y_max = bounding_box["y_max"]
95108
norm_rect = [
96109
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),
97110
orig_coord_to_norm_coord(orig_rect[1], orig_x_min=x_min, orig_x_max=x_max, orig_y_min=y_min, orig_y_max=y_max)

0 commit comments

Comments
 (0)