@@ -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