Skip to content

Commit 79a8336

Browse files
Finish transformation impl
1 parent 94a005d commit 79a8336

8 files changed

Lines changed: 2001 additions & 297 deletions

File tree

MIGRATION_GUIDE.md

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

13121481
## I/O and Build Dependencies
13131482

0 commit comments

Comments
 (0)