Skip to content

Commit 101aea7

Browse files
Transformation (#26)
* Transformation implementation (codex) * Finish transformation impl
1 parent 10dd419 commit 101aea7

13 files changed

Lines changed: 2611 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ nanobind_add_module(_core
1818
src/bindings/graph.cxx
1919
src/bindings/ground_truth.cxx
2020
src/bindings/segmentation.cxx
21+
src/bindings/transformation.cxx
2122
src/bindings/util.cxx
2223
src/bindings/utils.cxx
2324
src/cpp/segmentation/mutex_watershed.cxx

MIGRATION_GUIDE.md

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,6 +1293,229 @@ Implementation notes:
12931293
`detail/threading.hxx::parallel_for_chunks` without changing the
12941294
public API.
12951295

1296+
## Affine Transformations
1297+
1298+
`bioimage-cpp` exposes NumPy-only affine transformations under
1299+
`bic.transformation`. HDF5, zarr, N5, and OME-NGFF loading stays in Python;
1300+
load the desired chunk or subvolume first, then pass the NumPy array here.
1301+
1302+
Nifty:
1303+
1304+
```python
1305+
import nifty.transformation as nt
1306+
1307+
out = nt.affineTransformation(
1308+
data,
1309+
matrix,
1310+
order=1,
1311+
bounding_box=(slice(0, 64), slice(0, 64)),
1312+
fill_value=0,
1313+
)
1314+
```
1315+
1316+
bioimage-cpp:
1317+
1318+
```python
1319+
import bioimage_cpp as bic
1320+
1321+
out = bic.transformation.affine_transform(
1322+
data,
1323+
matrix,
1324+
bounding_box=(slice(0, 64), slice(0, 64)),
1325+
order=1,
1326+
fill_value=0,
1327+
)
1328+
```
1329+
1330+
Important differences from nifty:
1331+
1332+
- Only NumPy arrays are accepted. `affineTransformationH5`,
1333+
`affineTransformationZ5`, and coordinate-file transformations are not
1334+
reproduced.
1335+
- The API is snake_case only: `affine_transform`.
1336+
- `matrix` maps output coordinates to input coordinates in NumPy axis order.
1337+
Matrix shapes `(ndim, ndim + 1)` and homogeneous `(ndim + 1, ndim + 1)` are
1338+
accepted.
1339+
- `bounding_box=None` transforms `slice(0, data.shape[d])` for every axis.
1340+
Custom bounding boxes are one slice per axis and cannot use a step.
1341+
- Supported interpolation orders are `0` (nearest), `1` (linear),
1342+
`2`/`4`/`5` (quadratic / quartic / quintic B-spline), and `3` (Keys cubic
1343+
convolution, `a = -0.5`). The order set matches `scipy.ndimage`.
1344+
- Order `3` is *interpolating* (reproduces input samples at integer
1345+
coordinates). Orders `2`, `4`, `5` are *smoothing* B-spline kernels: they
1346+
exactly match `scipy.ndimage.affine_transform(..., prefilter=False,
1347+
mode='grid-constant')`, which is **not** scipy's default. We do not run
1348+
the cubic-spline IIR prefilter that scipy applies when `prefilter=True`,
1349+
so `bic.transformation.affine_transform(..., order=3)` is **not**
1350+
numerically equivalent to scipy's default `order=3`. See
1351+
`development/transformation/PERFORMANCE_NOTES.md` for the prefilter cost
1352+
analysis and the sketch of how we would add it. Practical guidance:
1353+
- For nifty parity, use `order=0` or `order=1`.
1354+
- For OpenCV-style "smooth cubic that hits the samples", use `order=3`.
1355+
- For scipy `prefilter=False` parity, use `order=2/4/5`.
1356+
- For scipy `prefilter=True` parity, you currently have to prefilter
1357+
the input yourself with `scipy.ndimage.spline_filter` before calling
1358+
our `affine_transform`.
1359+
- Border handling for orders 0, 1, and 3 follows
1360+
`scipy.ndimage.affine_transform(..., mode='constant')`: any output
1361+
coordinate that maps to an input coordinate inside `[0, shape - 1]`
1362+
along every axis is interpolated; coordinates fully outside are replaced
1363+
with `fill_value`. In particular the last row/column/slice is sampled
1364+
(nifty's older NumPy affine path treats the last index as out-of-bounds).
1365+
Orders `2/4/5` use `mode='grid-constant'` semantics: each kernel tap
1366+
independently picks up `fill_value` when it is out of bounds, with no
1367+
outer cliff at the input border.
1368+
- Output dtype is preserved for all supported input dtypes, including
1369+
integer inputs with linear, cubic, or spline interpolation. Integer
1370+
outputs round to the nearest integer and clamp to the dtype range, so
1371+
cubic / spline overshoots are well defined for `uint8`/`int8`/etc.
1372+
- An optional `out=` keyword writes the result into a pre-allocated
1373+
C-contiguous NumPy array of matching shape and dtype.
1374+
1375+
### Anti-aliased resampling
1376+
1377+
`affine_transform` itself never pre-smooths the input; downsampling without
1378+
prior low-pass filtering aliases. `bic.transformation.resample` is a thin
1379+
Python wrapper that computes a per-input-axis Gaussian sigma from the
1380+
matrix's linear part and pre-smooths via `bic.filters.gaussian_smoothing`
1381+
before sampling:
1382+
1383+
```python
1384+
import bioimage_cpp as bic
1385+
1386+
# Downsample by 2x on each axis, anti-aliased:
1387+
matrix = [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0]]
1388+
small = bic.transformation.resample(
1389+
image, matrix,
1390+
bounding_box=(slice(0, h // 2), slice(0, w // 2)),
1391+
order=1, # any supported order
1392+
anti_aliasing=True, # default; uses the heuristic sigma
1393+
)
1394+
1395+
# Explicit sigma (skips the heuristic):
1396+
small = bic.transformation.resample(image, matrix, anti_aliasing_sigma=[1.0, 1.0])
1397+
1398+
# Inspect what the heuristic would pick without resampling:
1399+
sigma = bic.transformation.compute_anti_aliasing_sigma(matrix, image.ndim)
1400+
```
1401+
1402+
The heuristic mirrors `skimage.transform.resize`: per input axis,
1403+
`sigma = max(0, (||row_of_linear_part|| - 1) / 2)`. Pure rotations
1404+
produce all-zero sigma (no smoothing); a uniform 2× downsample produces
1405+
`sigma = 0.5` per axis.
1406+
1407+
### Re-creating nifty's HDF5/zarr affine in Python
1408+
1409+
`bioimage-cpp` deliberately stops at NumPy; format-specific entry points
1410+
(`affineTransformationH5`, `affineTransformationZ5`) are out of scope for
1411+
the C++ core. For a downstream library that wants to recreate them, the
1412+
NumPy primitives compose naturally — chunk the **output** frame, read
1413+
just the input bounding box needed for each output chunk, transform with
1414+
`bic.transformation.affine_transform`, write the result back:
1415+
1416+
```python
1417+
import numpy as np
1418+
import bioimage_cpp as bic
1419+
1420+
def affine_transform_chunked(in_dataset, out_dataset, matrix, *,
1421+
output_shape, order=1, fill_value=0,
1422+
out_block_shape=(64, 256, 256), halo=None):
1423+
"""Apply an affine to a large array, one output block at a time.
1424+
1425+
`in_dataset` and `out_dataset` are array-like (numpy / h5py.Dataset /
1426+
zarr.Array / tensorstore / ...). `matrix` maps output coordinates to
1427+
input coordinates in NumPy axis order (the same convention as
1428+
`bic.transformation.affine_transform`).
1429+
"""
1430+
ndim = len(output_shape)
1431+
linear = np.asarray(matrix, dtype=np.float64)[:ndim, :ndim]
1432+
translation = np.asarray(matrix, dtype=np.float64)[:ndim, ndim]
1433+
# Default halo: kernel half-width per axis (order/2 rounded up) plus a
1434+
# safety margin for floating-point coordinate drift.
1435+
if halo is None:
1436+
halo = tuple([order + 2] * ndim)
1437+
1438+
in_shape = np.asarray(in_dataset.shape)
1439+
out_block = np.asarray(out_block_shape)
1440+
1441+
# Walk the output frame block by block.
1442+
block_starts = [
1443+
range(0, output_shape[k], out_block[k]) for k in range(ndim)
1444+
]
1445+
for corner in np.ndindex(*(len(b) for b in block_starts)):
1446+
out_start = np.array([block_starts[k][corner[k]] for k in range(ndim)])
1447+
out_stop = np.minimum(out_start + out_block, output_shape)
1448+
1449+
# 1. Find the input bounding box that all output voxels in this
1450+
# block could possibly sample. The 8 (2D: 4) corners of the
1451+
# output block are mapped through `matrix`; the axis-aligned
1452+
# bounding box of those mapped points (plus a halo) is what we
1453+
# need from the input array.
1454+
corners = np.stack(np.meshgrid(*[
1455+
[out_start[k], out_stop[k] - 1] for k in range(ndim)
1456+
], indexing="ij"), axis=-1).reshape(-1, ndim).astype(np.float64)
1457+
in_corners = corners @ linear.T + translation
1458+
in_lo = np.floor(in_corners.min(axis=0)).astype(np.int64) - np.asarray(halo)
1459+
in_hi = np.ceil(in_corners.max(axis=0)).astype(np.int64) + np.asarray(halo)
1460+
1461+
# 2. Clip to the input array. Anything outside becomes fill_value
1462+
# via the affine_transform's border handling.
1463+
in_lo_clipped = np.maximum(in_lo, 0)
1464+
in_hi_clipped = np.minimum(in_hi, in_shape)
1465+
if np.any(in_hi_clipped <= in_lo_clipped):
1466+
# Output block lies entirely outside the input frame.
1467+
out_block_data = np.full(
1468+
tuple((out_stop - out_start).tolist()),
1469+
fill_value, dtype=out_dataset.dtype,
1470+
)
1471+
else:
1472+
slicer = tuple(slice(int(lo), int(hi))
1473+
for lo, hi in zip(in_lo_clipped, in_hi_clipped))
1474+
in_block = np.ascontiguousarray(in_dataset[slicer])
1475+
1476+
# 3. Translate `matrix` into the input-block-local frame.
1477+
# Our convention: input = linear @ output + translation.
1478+
# For the local block, input_local = input - in_lo_clipped.
1479+
local_matrix = np.hstack([linear, (translation - in_lo_clipped)[:, None]])
1480+
1481+
# 4. Run the affine on the in-memory block. We pass the local
1482+
# bounding box in **output** coordinates: this block of the
1483+
# output spans (out_start, out_stop).
1484+
out_block_data = bic.transformation.affine_transform(
1485+
in_block,
1486+
local_matrix,
1487+
bounding_box=tuple(slice(int(a), int(b))
1488+
for a, b in zip(out_start, out_stop)),
1489+
order=order,
1490+
fill_value=fill_value,
1491+
)
1492+
1493+
out_dataset[tuple(slice(int(a), int(b))
1494+
for a, b in zip(out_start, out_stop))] = out_block_data
1495+
```
1496+
1497+
Notes:
1498+
1499+
- The halo accounts for the kernel's tap reach; the safety margin handles
1500+
floating-point drift in the corner mapping. `order + 2` is conservative
1501+
for orders ≤ 5.
1502+
- This pattern works for `h5py.Dataset`, `zarr.Array`, `tensorstore`, or
1503+
any other lazy-array library that supports NumPy-style indexing — there
1504+
is nothing format-specific in the body.
1505+
- For best throughput, choose `out_block_shape` to match the on-disk
1506+
chunking of `out_dataset` (one block = one chunk write) and large enough
1507+
in each axis that the input-side read is also full chunks.
1508+
- Anti-aliasing for downsampling pipelines: replace
1509+
`bic.transformation.affine_transform(...)` in step 4 with
1510+
`bic.transformation.resample(...)`. The Gaussian sigma is derived from
1511+
`matrix` and is identical for every block, so the per-block smoothing
1512+
cost is constant.
1513+
- For random-access transformations (large rotations, perspective warps)
1514+
the per-block input bounding box can be much larger than the output
1515+
block. A real implementation should either cap the read size and skip
1516+
obviously-empty output blocks, or partition the output into a finer grid
1517+
for those cases.
1518+
12961519
## I/O and Build Dependencies
12971520

12981521
`bioimage-cpp` intentionally does not replace nifty or affogato I/O helpers.

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ segmentation = bic.segmentation.mutex_watershed(
4141
)
4242
```
4343

44+
```python
45+
image = np.arange(64, dtype=np.float32).reshape(8, 8)
46+
matrix = np.array([[1.0, 0.0, 0.5], [0.0, 1.0, -1.0]])
47+
48+
transformed = bic.transformation.affine_transform(
49+
image,
50+
matrix,
51+
order=3,
52+
fill_value=0,
53+
)
54+
```
55+
4456
```python
4557
graph = bic.graph.UndirectedGraph.from_edges(4, [[0, 1], [1, 2], [2, 3]])
4658
graph.find_edge(2, 1)

0 commit comments

Comments
 (0)