Skip to content

Commit 1f8984c

Browse files
Grid graph (#14)
* Implement grid graph features * Add integration tests and comparison scripts * Some optimization of grid graph creation and features * Optimization by claude * More optimization * More optimization
1 parent b9741ef commit 1f8984c

14 files changed

Lines changed: 2688 additions & 42 deletions

MIGRATION_GUIDE.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ Important differences:
107107
- Bulk methods accept array-like inputs and return NumPy arrays.
108108
- Python-style names are preferred. A few nifty-style aliases are still present
109109
on `UndirectedGraph` for convenience, but new code should use snake_case.
110+
- `graph.clone()` returns an independent deep copy. The C++ class is
111+
move-only (it owns a CSR adjacency buffer), so prefer this over
112+
reassignment-by-value.
113+
- `graph.freeze()` eagerly builds the internal adjacency. Call it after a
114+
batch of `insert_edge` calls if you intend to hand the graph to multiple
115+
reader threads, or if you want to ensure subsequent `node_adjacency`
116+
reads carry no first-call rebuild cost.
110117

111118
Common method/property mapping:
112119

@@ -126,6 +133,49 @@ Common method/property mapping:
126133
| `extractSubgraphFromNodes` | `extract_subgraph_from_nodes` |
127134
| `edgesFromNodeList` | `edges_from_node_list` |
128135

136+
## Grid Graphs
137+
138+
Nifty-style regular grid graphs map to explicit 2D or 3D grid graph classes:
139+
140+
```python
141+
graph = bic.graph.GridGraph2D((height, width))
142+
graph = bic.graph.GridGraph3D((depth, height, width))
143+
graph = bic.graph.grid_graph((height, width))
144+
```
145+
146+
Grid graph nodes use NumPy C-order ids. For a 2D shape `(y, x)`, node
147+
`(row, col)` has id `row * x + col`; for 3D `(z, y, x)`, ids follow the same
148+
row-major convention. `GridGraph2D` and `GridGraph3D` inherit the regular
149+
`UndirectedGraph` API, so solvers, connected components, breadth-first search,
150+
and `uv_ids()` work unchanged.
151+
152+
Important differences:
153+
154+
- Only nearest-neighbor 2D and 3D grids are exposed for now.
155+
- Edge ids are deterministic axis blocks: axis 0 edges first, then axis 1, and
156+
axis 2 for 3D.
157+
- Scalar boundary maps can be converted to edge weights with
158+
`grid_boundary_features(graph, boundary_map)`.
159+
- Local affinity channels can be converted to edge-aligned weights with
160+
`grid_affinity_features(graph, affinities, offsets)`.
161+
- Mixed local and long-range affinity offsets can be converted with
162+
`grid_affinity_features_with_lifted(...)`, which returns local graph weights
163+
plus explicit long-range `uv_ids` and weights for lifted multicut or mutex
164+
watershed style workflows.
165+
- The three grid feature functions preserve `float32` and `float64` input
166+
dtype end-to-end (no internal copy to `float64`); other dtypes are
167+
promoted to `float64`. Output weight arrays match the input dtype.
168+
- Grid graph construction does not materialize the per-node adjacency
169+
list. If you only need `uv_ids()` and edge features (the common case)
170+
you pay nothing for adjacency. The first call to `node_adjacency`,
171+
`connected_components`, `breadth_first_search`, or
172+
`extract_subgraph_from_nodes` on a grid graph triggers a one-shot
173+
rebuild; call `graph.freeze()` on the construction thread before
174+
fan-out if you intend to use those from multiple threads.
175+
- Affogato-style masks and seed edges are not part of the public grid feature
176+
API yet; the implementation is structured so these filters/extra edges can
177+
be added later.
178+
129179
## Region Adjacency Graphs
130180

131181
Nifty:

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,15 @@ segmentation = bic.segmentation.mutex_watershed(
4545
graph = bic.graph.UndirectedGraph.from_edges(4, [[0, 1], [1, 2], [2, 3]])
4646
graph.find_edge(2, 1)
4747
# 1
48+
49+
grid = bic.graph.grid_graph((64, 64))
50+
grid.number_of_edges
51+
# 8064
52+
53+
edge_weights = bic.graph.grid_boundary_features(grid, boundary_map)
54+
local_weights, valid_edges, lifted_uvs, lifted_weights, offset_ids = (
55+
bic.graph.grid_affinity_features_with_lifted(grid, affinities, offsets)
56+
)
4857
```
4958

5059
## Scope
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
from pathlib import Path
5+
from statistics import median
6+
from time import perf_counter
7+
from typing import Callable
8+
9+
import numpy as np
10+
11+
12+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
13+
DEFAULT_DATA_PREFIX = PROJECT_ROOT / "examples" / "segmentation" / "isbi-data-"
14+
15+
16+
def load_problem(data_prefix: Path | str = DEFAULT_DATA_PREFIX):
17+
from elf.segmentation.utils import load_mutex_watershed_problem
18+
19+
affinities, offsets = load_mutex_watershed_problem(prefix=str(data_prefix))
20+
return np.ascontiguousarray(affinities), [tuple(offset) for offset in offsets]
21+
22+
23+
def prepare_2d_problem(
24+
affinities: np.ndarray,
25+
offsets: list[tuple[int, ...]],
26+
z: int,
27+
yx_shape: tuple[int, int],
28+
):
29+
channels_2d = [index for index, offset in enumerate(offsets) if offset[0] == 0]
30+
y, x = yx_shape
31+
affinities_2d = affinities[channels_2d, z, :y, :x]
32+
offsets_2d = [offsets[index][1:] for index in channels_2d]
33+
return np.ascontiguousarray(affinities_2d, dtype=np.float32), offsets_2d
34+
35+
36+
def prepare_3d_problem(
37+
affinities: np.ndarray,
38+
offsets: list[tuple[int, ...]],
39+
zyx_shape: tuple[int, int, int],
40+
):
41+
z, y, x = zyx_shape
42+
cropped = affinities[:, :z, :y, :x]
43+
return np.ascontiguousarray(cropped, dtype=np.float32), offsets
44+
45+
46+
def select_local_offsets(
47+
affinities: np.ndarray,
48+
offsets: list[tuple[int, ...]],
49+
):
50+
local_channels = [
51+
index for index, offset in enumerate(offsets)
52+
if sum(abs(value) for value in offset) == 1
53+
]
54+
local_affinities = np.ascontiguousarray(affinities[local_channels], dtype=np.float32)
55+
local_offsets = [tuple(offsets[index]) for index in local_channels]
56+
return local_affinities, local_offsets
57+
58+
59+
def select_mixed_offsets(
60+
affinities: np.ndarray,
61+
offsets: list[tuple[int, ...]],
62+
):
63+
selected = [
64+
index for index, offset in enumerate(offsets)
65+
if sum(abs(value) for value in offset) >= 1
66+
]
67+
mixed_affinities = np.ascontiguousarray(affinities[selected], dtype=np.float32)
68+
mixed_offsets = [tuple(offsets[index]) for index in selected]
69+
return mixed_affinities, mixed_offsets
70+
71+
72+
def time_call(function: Callable[[], tuple[np.ndarray, np.ndarray]], repeats: int):
73+
# One untimed warm-up before the measured loop. The first call typically
74+
# pays for code-page faults and one-time library initialization
75+
# (nanobind tuple shapes, numpy ufunc caches, ...) that aren't part of
76+
# the steady-state cost we care about. Without warm-up these costs leak
77+
# into the first sample and skew the median for low `repeats` values.
78+
function()
79+
timings = []
80+
result = None
81+
for _ in range(repeats):
82+
start = perf_counter()
83+
result = function()
84+
timings.append(perf_counter() - start)
85+
assert result is not None
86+
return timings, result
87+
88+
89+
def sorted_edges_and_weights(uvs: np.ndarray, weights: np.ndarray):
90+
uvs = np.asarray(uvs, dtype=np.uint64)
91+
weights = np.asarray(weights, dtype=np.float64)
92+
if uvs.shape[0] == 0:
93+
return uvs.reshape(0, 2), weights.reshape(0)
94+
normalized = np.sort(uvs, axis=1)
95+
order = np.lexsort((normalized[:, 1], normalized[:, 0]))
96+
return normalized[order], weights[order]
97+
98+
99+
def split_affogato_edges(uvs: np.ndarray, weights: np.ndarray, graph):
100+
local_mask = np.asarray(graph.find_edges(uvs), dtype=np.int64) >= 0
101+
return (
102+
uvs[local_mask],
103+
weights[local_mask],
104+
uvs[~local_mask],
105+
weights[~local_mask],
106+
)
107+
108+
109+
def bioimage_cpp_local(affinities: np.ndarray, offsets: list[tuple[int, ...]]):
110+
import bioimage_cpp as bic
111+
112+
graph = bic.graph.grid_graph(affinities.shape[1:])
113+
weights, _ = bic.graph.grid_affinity_features(graph, affinities, offsets)
114+
return graph.uv_ids(), weights
115+
116+
117+
def bioimage_cpp_local_weights_only(
118+
graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]
119+
):
120+
"""Compute edge weights only — no (uvs, weights) materialization.
121+
122+
This isolates the cost of the feature kernel from the cost of returning
123+
the canonical uv_ids array. Use this when comparing against libraries
124+
that already cache uvs in the graph object.
125+
"""
126+
import bioimage_cpp as bic
127+
128+
weights, _ = bic.graph.grid_affinity_features(graph, affinities, offsets)
129+
return weights
130+
131+
132+
def bioimage_cpp_local_with_uvs(
133+
graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]
134+
):
135+
"""Compute weights AND materialize uvs — apples-to-apples with nifty's
136+
``affinitiesToEdgeMapWithOffsets`` and affogato's
137+
``compute_nh_and_weights``, both of which return uvs in their output."""
138+
import bioimage_cpp as bic
139+
140+
weights, _ = bic.graph.grid_affinity_features(graph, affinities, offsets)
141+
return graph.uv_ids(), weights
142+
143+
144+
def bioimage_cpp_lifted(affinities: np.ndarray, offsets: list[tuple[int, ...]]):
145+
import bioimage_cpp as bic
146+
147+
graph = bic.graph.grid_graph(affinities.shape[1:])
148+
local_weights, _, lifted_uvs, lifted_weights, _ = (
149+
bic.graph.grid_affinity_features_with_lifted(graph, affinities, offsets)
150+
)
151+
return graph, graph.uv_ids(), local_weights, lifted_uvs, lifted_weights
152+
153+
154+
def bioimage_cpp_lifted_features_only(
155+
graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]
156+
):
157+
"""Lifted features without graph.uv_ids() — see the local variant."""
158+
import bioimage_cpp as bic
159+
160+
local_weights, _, lifted_uvs, lifted_weights, _ = (
161+
bic.graph.grid_affinity_features_with_lifted(graph, affinities, offsets)
162+
)
163+
return local_weights, lifted_uvs, lifted_weights
164+
165+
166+
def bioimage_cpp_lifted_with_uvs(
167+
graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]
168+
):
169+
"""Lifted features WITH local uvs (apples-to-apples with affogato)."""
170+
import bioimage_cpp as bic
171+
172+
local_weights, _, lifted_uvs, lifted_weights, _ = (
173+
bic.graph.grid_affinity_features_with_lifted(graph, affinities, offsets)
174+
)
175+
return graph.uv_ids(), local_weights, lifted_uvs, lifted_weights
176+
177+
178+
def assert_local_offsets_cover_all_edges(graph, affinities, offsets) -> None:
179+
"""One-shot correctness check called outside of the timing loop."""
180+
import bioimage_cpp as bic
181+
182+
_, valid_edges = bic.graph.grid_affinity_features(graph, affinities, offsets)
183+
if not np.all(valid_edges):
184+
raise AssertionError("local offsets did not cover all grid edges")
185+
186+
187+
def nifty_local(affinities: np.ndarray, offsets: list[tuple[int, ...]]):
188+
import nifty.graph as ng
189+
190+
graph = ng.undirectedGridGraph(list(affinities.shape[1:]))
191+
return nifty_local_on_graph(graph, affinities, offsets)
192+
193+
194+
def nifty_local_on_graph(graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]):
195+
n_edges, uvs, weights = graph.affinitiesToEdgeMapWithOffsets(
196+
affinities,
197+
[list(offset) for offset in offsets],
198+
)
199+
return np.asarray(uvs[:n_edges], dtype=np.uint64), np.asarray(weights[:n_edges])
200+
201+
202+
def affogato_edges(affinities: np.ndarray, offsets: list[tuple[int, ...]]):
203+
from affogato.segmentation import MWSGridGraph
204+
205+
graph = MWSGridGraph(list(affinities.shape[1:]))
206+
return affogato_edges_on_graph(graph, affinities, offsets)
207+
208+
209+
def affogato_edges_on_graph(graph, affinities: np.ndarray, offsets: list[tuple[int, ...]]):
210+
uvs, weights = graph.compute_nh_and_weights(
211+
affinities,
212+
[list(offset) for offset in offsets],
213+
strides=[1] * (affinities.ndim - 1),
214+
randomize_strides=False,
215+
)
216+
return np.asarray(uvs, dtype=np.uint64), np.asarray(weights)
217+
218+
219+
def compare_edge_sets(
220+
name: str,
221+
candidate_uvs: np.ndarray,
222+
candidate_weights: np.ndarray,
223+
reference_uvs: np.ndarray,
224+
reference_weights: np.ndarray,
225+
):
226+
candidate_uvs, candidate_weights = sorted_edges_and_weights(
227+
candidate_uvs, candidate_weights
228+
)
229+
reference_uvs, reference_weights = sorted_edges_and_weights(
230+
reference_uvs, reference_weights
231+
)
232+
np.testing.assert_array_equal(candidate_uvs, reference_uvs)
233+
np.testing.assert_allclose(candidate_weights, reference_weights, rtol=1.0e-6, atol=1.0e-6)
234+
max_abs_diff = (
235+
float(np.max(np.abs(candidate_weights - reference_weights)))
236+
if candidate_weights.size
237+
else 0.0
238+
)
239+
return {
240+
"name": name,
241+
"number_of_edges": int(candidate_uvs.shape[0]),
242+
"max_abs_weight_diff": max_abs_diff,
243+
}
244+
245+
246+
def print_timing(name: str, first_name: str, first_timings: list[float],
247+
second_name: str, second_timings: list[float]):
248+
first_median = median(first_timings)
249+
second_median = median(second_timings)
250+
ratio = second_median / first_median if first_median > 0 else float("inf")
251+
print(f"{name} {first_name} median runtime: {first_median:.6f} s")
252+
print(f"{name} {second_name} median runtime: {second_median:.6f} s")
253+
print(f"{name} {second_name} / {first_name} runtime ratio: {ratio:.3f}x")
254+
255+
256+
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
257+
parser.add_argument("--ndim", type=int, choices=(2, 3), default=2)
258+
parser.add_argument("--data-prefix", type=Path, default=DEFAULT_DATA_PREFIX)
259+
# Default bumped from 3 to 5 — median of 3 is the middle sample and is
260+
# noisy if anything (GC, cache eviction) lands inside one of the three
261+
# runs. With `time_call` doing one warm-up before this, 5 samples gives
262+
# a usable median without much added cost.
263+
parser.add_argument("--repeats", type=int, default=5)
264+
parser.add_argument("--z", type=int, default=20)
265+
parser.add_argument("--yx-shape", type=int, nargs=2, default=(512, 512))
266+
parser.add_argument("--zyx-shape", type=int, nargs=3, default=(16, 512, 512))
267+
# Affinity dtype that every library receives. nifty and affogato accept
268+
# both float32 and float64 at near-identical speed (verified separately),
269+
# and bioimage-cpp now templates on the value type, so feeding all three
270+
# the same dtype removes the previous implicit float32 -> float64 copy
271+
# that was charged only to bioimage-cpp.
272+
parser.add_argument(
273+
"--dtype", choices=("float32", "float64"), default="float32"
274+
)

0 commit comments

Comments
 (0)