|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
from typing import Any, Optional, Tuple |
|
|
|
|
|
import numpy as np |
|
|
|
|
|
|
|
|
def get_1d_padding(length: int, tile_size: int) -> Tuple[int, int]: |
|
|
"""Computes symmetric padding for `length` to be divisible by `tile_size`.""" |
|
|
pad = (tile_size - length % tile_size) % tile_size |
|
|
return (pad // 2, pad - pad // 2) |
|
|
|
|
|
|
|
|
def pad_for_tiling_2d(array: np.ndarray, tile_size: int, channels_first: Optional[bool] = True, |
|
|
**pad_kwargs: Any) -> Tuple[np.ndarray, np.ndarray]: |
|
|
"""Symmetrically pads a 2D `array` such that both dimensions are divisible by `tile_size`. |
|
|
|
|
|
:param array: 2D image array. |
|
|
:param tile_size: Width/height of each tile in pixels. |
|
|
:param channels_first: Whether `array` is in CHW (`True`, default) or HWC (`False`) layout. |
|
|
:param pad_kwargs: Keyword arguments to be passed to `np.pad()` (e.g. `constant_values=0`). |
|
|
:return: A tuple containing: |
|
|
- `padded_array`: Resulting array, in the same CHW/HWC layout as the input. |
|
|
- `offset`: XY offset introduced by the padding. Add this to coordinates relative to the |
|
|
original array to obtain indices for the padded array. |
|
|
""" |
|
|
height, width = array.shape[1:] if channels_first else array.shape[:-1] |
|
|
padding_h = get_1d_padding(height, tile_size) |
|
|
padding_w = get_1d_padding(width, tile_size) |
|
|
padding = [padding_h, padding_w] |
|
|
channels_axis = 0 if channels_first else 2 |
|
|
padding.insert(channels_axis, (0, 0)) |
|
|
padded_array = np.pad(array, padding, **pad_kwargs) |
|
|
offset = (padding_w[0], padding_h[0]) |
|
|
return padded_array, np.array(offset) |
|
|
|
|
|
|
|
|
def tile_array_2d(array: np.ndarray, tile_size: int, channels_first: Optional[bool] = True, |
|
|
**pad_kwargs: Any) -> Tuple[np.ndarray, np.ndarray]: |
|
|
"""Split an image array into square non-overlapping tiles. |
|
|
|
|
|
The array will be padded symmetrically if its dimensions are not exact multiples of `tile_size`. |
|
|
|
|
|
:param array: Image array. |
|
|
:param tile_size: Width/height of each tile in pixels. |
|
|
:param pad_kwargs: Keyword arguments to be passed to `np.pad()` (e.g. `constant_values=0`). |
|
|
:param channels_first: Whether `array` is in CHW (`True`, default) or HWC (`False`) layout. |
|
|
:return: A tuple containing: |
|
|
- `tiles`: A batch of tiles in NCHW layout. |
|
|
- `coords`: XY coordinates of each tile, in the same order. |
|
|
""" |
|
|
padded_array, (offset_w, offset_h) = pad_for_tiling_2d(array, tile_size, channels_first, **pad_kwargs) |
|
|
if channels_first: |
|
|
channels, height, width = padded_array.shape |
|
|
else: |
|
|
height, width, channels = padded_array.shape |
|
|
n_tiles_h = height // tile_size |
|
|
n_tiles_w = width // tile_size |
|
|
|
|
|
if channels_first: |
|
|
intermediate_shape = (channels, n_tiles_h, tile_size, n_tiles_w, tile_size) |
|
|
axis_order = (1, 3, 0, 2, 4) |
|
|
output_shape = (n_tiles_h * n_tiles_w, channels, tile_size, tile_size) |
|
|
else: |
|
|
intermediate_shape = (n_tiles_h, tile_size, n_tiles_w, tile_size, channels) |
|
|
axis_order = (0, 2, 1, 3, 4) |
|
|
output_shape = (n_tiles_h * n_tiles_w, tile_size, tile_size, channels) |
|
|
|
|
|
tiles = padded_array.reshape(intermediate_shape) |
|
|
tiles = tiles.transpose(axis_order) |
|
|
tiles = tiles.reshape(output_shape) |
|
|
|
|
|
|
|
|
coords_h = tile_size * np.arange(n_tiles_h) - offset_h |
|
|
coords_w = tile_size * np.arange(n_tiles_w) - offset_w |
|
|
|
|
|
coords = np.stack(np.meshgrid(coords_w, coords_h), axis=-1).reshape(-1, 2) |
|
|
|
|
|
return tiles, coords |
|
|
|
|
|
|
|
|
def assemble_tiles_2d(tiles: np.ndarray, coords: np.ndarray, fill_value: Optional[float] = np.nan, |
|
|
channels_first: Optional[bool] = True) -> Tuple[np.ndarray, np.ndarray]: |
|
|
"""Assembles a 2D array from sequences of tiles and coordinates. |
|
|
|
|
|
:param tiles: Stack of tiles with batch dimension first. |
|
|
:param coords: XY tile coordinates, assumed to be spaced by multiples of `tile_size` (shape: [N, 2]). |
|
|
:param tile_size: Size of each tile; must be >0. |
|
|
:param fill_value: Value to assign to empty elements (default: `NaN`). |
|
|
:param channels_first: Whether each tile is in CHW (`True`, default) or HWC (`False`) layout. |
|
|
:return: A tuple containing: |
|
|
- `array`: The reassembled 2D array with the smallest dimensions to contain all given tiles. |
|
|
- `offset`: The lowest XY coordinates. |
|
|
- `offset`: XY offset introduced by the assembly. Add this to tile coordinates to obtain |
|
|
indices for the assembled array. |
|
|
""" |
|
|
if coords.shape[0] != tiles.shape[0]: |
|
|
raise ValueError(f"Tile coordinates and values must have the same length, " |
|
|
f"got {coords.shape[0]} and {tiles.shape[0]}") |
|
|
|
|
|
if channels_first: |
|
|
n_tiles, channels, tile_size, _ = tiles.shape |
|
|
else: |
|
|
n_tiles, tile_size, _, channels = tiles.shape |
|
|
tile_xs, tile_ys = coords.T |
|
|
|
|
|
x_min, x_max = min(tile_xs), max(tile_xs + tile_size) |
|
|
y_min, y_max = min(tile_ys), max(tile_ys + tile_size) |
|
|
width = x_max - x_min |
|
|
height = y_max - y_min |
|
|
output_shape = (channels, height, width) if channels_first else (height, width, channels) |
|
|
array = np.full(output_shape, fill_value) |
|
|
|
|
|
offset = np.array([-x_min, -y_min]) |
|
|
for idx in range(n_tiles): |
|
|
row = coords[idx, 1] + offset[1] |
|
|
col = coords[idx, 0] + offset[0] |
|
|
if channels_first: |
|
|
array[:, row:row + tile_size, col:col + tile_size] = tiles[idx] |
|
|
else: |
|
|
array[row:row + tile_size, col:col + tile_size, :] = tiles[idx] |
|
|
|
|
|
return array, offset |
|
|
|