@@ -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.
0 commit comments