Skip to content

Commit f2bb4e1

Browse files
Merge branch 'main' into lifted-ctd
2 parents f1b680a + 1d445c9 commit f2bb4e1

39 files changed

Lines changed: 4930 additions & 278 deletions

MIGRATION_GUIDE.md

Lines changed: 128 additions & 2 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:
@@ -677,8 +727,8 @@ shims default to sample A, size small and continue to honor the
677727
`BIOIMAGE_CPP_EXTERNAL_MULTICUT_PATH` and
678728
`BIOIMAGE_CPP_EXTERNAL_MULTICUT_CACHE` environment variables.
679729

680-
Lifted multicut problems (2D ISBI slice and full 3D volume, built by
681-
`examples/segmentation/serialize_lifted_problem.py`):
730+
Lifted multicut problems (2D ISBI slice, RAG-based 3D volume, and grid-graph
731+
volume):
682732

683733
```python
684734
problem = bic.graph.load_lifted_multicut_problem(size="2d")
@@ -692,6 +742,8 @@ objective = bic.graph.LiftedMulticutObjective(
692742
)
693743
```
694744

745+
Valid sizes are `"2d"`, `"3d"`, and `"grid"`.
746+
695747
Notes:
696748

697749
- Every download is integrity-checked against a SHA256 in the registry; a
@@ -790,6 +842,13 @@ Notes:
790842

791843
## Mutex Watershed
792844

845+
`bioimage-cpp` ships two mutex-watershed entry points, mirroring the two
846+
affogato APIs: one that consumes a dense affinity grid, and one that
847+
consumes an arbitrary graph with a separate list of mutex (long-range
848+
repulsive) edges.
849+
850+
### Grid-based mutex watershed (affinity volumes)
851+
793852
Affogato:
794853

795854
```python
@@ -832,6 +891,73 @@ Important migration notes:
832891
masked pixels are set to label `0`.
833892
- Output labels are `uint64`, consecutive, and 1-based for foreground pixels.
834893

894+
### Mutex watershed on a generic graph
895+
896+
For mutex watershed on an arbitrary undirected graph (region adjacency graph
897+
or otherwise) with a separate list of long-range repulsive edges,
898+
`bioimage-cpp` provides `bic.graph.mutex_watershed_clustering`. This is a
899+
port of affogato's `compute_mws_clustering` using the same input format as
900+
`LiftedMulticutObjective`: a base graph carries the attractive edges, and
901+
long-range (called *mutex* here) edges are supplied alongside as a `(M, 2)`
902+
node-pair array. The same `(graph, edge_costs, lifted_uvs, lifted_costs)`
903+
tuple used to build a lifted multicut problem can be passed to the mutex
904+
watershed clustering without any reshaping.
905+
906+
Affogato:
907+
908+
```python
909+
from affogato.segmentation import compute_mws_clustering
910+
911+
labels = compute_mws_clustering(
912+
number_of_nodes,
913+
uvs.astype(np.uint64),
914+
mutex_uvs.astype(np.uint64),
915+
weights.astype(np.float32),
916+
mutex_weights.astype(np.float32),
917+
)
918+
```
919+
920+
bioimage-cpp:
921+
922+
```python
923+
import bioimage_cpp as bic
924+
925+
graph = bic.graph.UndirectedGraph.from_edges(number_of_nodes, uvs)
926+
labels = bic.graph.mutex_watershed_clustering(
927+
graph,
928+
weights,
929+
mutex_uvs,
930+
mutex_weights,
931+
)
932+
```
933+
934+
Notes:
935+
936+
- The attractive edges are the edges of the base graph; the count and the
937+
ordering of `weights` must match `graph.number_of_edges`. Mutex edges
938+
are supplied separately as `(M, 2)` `uint64` pairs with matching
939+
`mutex_weights`.
940+
- Both `weights` and `mutex_weights` accept `float32` and `float64`. The
941+
wrapper dispatches to a templated C++ instantiation per dtype; other
942+
floating dtypes are cast to `float32`. If the two arrays' dtypes do not
943+
match, both are promoted to `float64` rather than silently downcast.
944+
- Higher weights are processed first (in descending order) — the same
945+
convention affogato uses.
946+
- The implementation reuses the union-find and per-root mutex-set helpers
947+
shared with the grid-based mutex watershed (`detail/mutex_storage.hxx`),
948+
so behavior is consistent between the two entry points.
949+
- Output labels are dense `uint64` ids in `0 .. number_of_clusters - 1`,
950+
assigned in first-occurrence order (matches the convention of the graph
951+
multicut solvers, *not* the 1-based foreground labels produced by the
952+
grid-based variant).
953+
- The function accepts both `UndirectedGraph` and `RegionAdjacencyGraph`.
954+
- Tie-breaking is deterministic: when weights are equal, attractive edges
955+
are processed before mutex edges, then by index. Affogato's reference
956+
uses a non-stable `std::sort`, so on inputs with many ties the two
957+
implementations may produce slightly different (but very similar)
958+
partitions. See `development/graph/check_mutex_clustering.py` for a
959+
comparison harness.
960+
835961
## Dictionary-Based Relabeling
836962

837963
If you used a small helper to apply a dictionary to an integer label array, use

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

0 commit comments

Comments
 (0)