Source code for shapelib.tools.converters.transform_utils
"""Utilities for affine transform adjustments in raster-to-vector conversions."""
import numpy as np
import rasterio
from rasterio.io import DatasetReader
from rasterio.windows import Window
[docs]
def get_transform_with_resampling(
dataset_reader: DatasetReader,
window: Window | None = None,
target_resolution_cm_per_pixel: float | None = None,
) -> rasterio.Affine:
"""
Get affine transform from dataset, optionally adjusted for resampling.
This function retrieves the affine transform from a rasterio dataset reader,
with optional adjustment for window regions and resolution resampling. When
masks are generated from resampled imagery, the transform must be adjusted
to maintain correct spatial positioning.
Parameters
----------
dataset_reader : rasterio.io.DatasetReader
Rasterio dataset reader containing the original transform and CRS.
window : rasterio.windows.Window, optional
If provided, the transform will be adjusted for the window region.
target_resolution_cm_per_pixel : float, optional
Target resolution in centimeters per pixel if the mask was resampled.
If provided and different from the dataset's original resolution, the
transform will be automatically adjusted to account for resampling.
Returns
-------
rasterio.Affine
Affine transform, potentially adjusted for window and/or resampling.
Examples
--------
>>> import rasterio
>>> with rasterio.open('image.tif') as src:
... # Get transform without adjustments
... transform1 = get_transform_with_resampling(src)
...
... # Get transform adjusted for resampling
... transform2 = get_transform_with_resampling(
... src,
... target_resolution_cm_per_pixel=3.0
... )
...
... # Get transform for window with resampling
... window = rasterio.windows.Window(0, 0, 100, 100)
... transform3 = get_transform_with_resampling(
... src,
... window=window,
... target_resolution_cm_per_pixel=3.0
... )
"""
transform = dataset_reader.window_transform(window) if window is not None else dataset_reader.transform
if target_resolution_cm_per_pixel is not None:
transform = adjust_transform_for_resampling(
transform=transform,
target_resolution_cm_per_pixel=target_resolution_cm_per_pixel,
)
return transform
[docs]
def adjust_transform_for_resampling(
transform: rasterio.Affine,
target_resolution_cm_per_pixel: float,
) -> rasterio.Affine:
"""
Adjust an affine transform to account for mask resampling.
When a mask is resampled to a different resolution, its pixel coordinates
no longer align with the original dataset's transform. This function computes
a corrected transform where pixel sizes are scaled appropriately while
maintaining the geographic origin.
Parameters
----------
transform : rasterio.Affine
Original affine transform from the dataset.
target_resolution_cm_per_pixel : float
Target resolution in centimeters per pixel that the mask was resampled to.
Returns
-------
rasterio.Affine
Adjusted transform with corrected pixel sizes for the resampled mask.
Raises
------
ValueError
If the calculated scale factor is invalid (<=0, infinite, or NaN).
Notes
-----
The transform adjustment formula:
scale_factor = original_resolution / target_resolution
adjusted_pixel_size = original_pixel_size / scale_factor
This works for both:
- Downsampling (scale_factor > 1): finer resolution, smaller pixels
- Upsampling (scale_factor < 1): coarser resolution, larger pixels
If the original resolution matches the target resolution (within 0.001%
tolerance), the transform is returned unchanged to avoid unnecessary
computation and potential floating-point errors.
Examples
--------
>>> import rasterio
>>> # Original: 6 cm/pixel, downsampled to 3 cm/pixel (scale = 2.0)
>>> original_transform = rasterio.Affine(0.06, 0, 100, 0, -0.06, 200)
>>> adjusted = adjust_transform_for_resampling(original_transform, 3.0)
>>> # adjusted pixel size will be 0.03m (half of original)
"""
original_resolution_x_cm = abs(transform.a) * 100.0
original_resolution_y_cm = abs(transform.e) * 100.0
if not np.isclose(original_resolution_x_cm, target_resolution_cm_per_pixel, rtol=1e-5):
scale_factor_x = original_resolution_x_cm / target_resolution_cm_per_pixel
scale_factor_y = original_resolution_y_cm / target_resolution_cm_per_pixel
if (
scale_factor_x <= 0
or scale_factor_y <= 0
or not np.isfinite(scale_factor_x)
or not np.isfinite(scale_factor_y)
):
raise ValueError(
f"Invalid scale factors: x={scale_factor_x:.3f}, y={scale_factor_y:.3f}. "
f"Original resolution: ({original_resolution_x_cm:.2f}, "
f"{original_resolution_y_cm:.2f}) cm/pixel, "
f"Target resolution: {target_resolution_cm_per_pixel:.2f} cm/pixel"
)
transform = rasterio.Affine(
transform.a / scale_factor_x,
transform.b,
transform.c,
transform.d,
transform.e / scale_factor_y,
transform.f,
)
return transform
[docs]
def pad_mask_to_min_size(mask: np.ndarray, min_size: tuple[int, int], fill_value: int) -> np.ndarray:
"""
Pad a mask to match a minimum size.
If the mask is already equal to or larger than min_size in both dimensions,
returns the mask unchanged. Otherwise, pads with fill_value to match the
exact minimum size. Padding is applied to the right and bottom to preserve
spatial alignment.
Parameters
----------
mask : np.ndarray
The input mask to pad (2D array).
min_size : tuple[int, int]
Minimum size (height, width) for the output mask.
fill_value : int
Value to use for padding.
Returns
-------
np.ndarray
The padded mask with shape matching min_size, or the original mask
if it already meets or exceeds the minimum size.
Examples
--------
>>> import numpy as np
>>> mask = np.ones((100, 80), dtype=np.uint8)
>>> padded = pad_mask_to_min_size(mask, (256, 256), fill_value=0)
>>> print(padded.shape)
(256, 256)
>>> # No padding when already large enough
>>> large_mask = np.ones((300, 300), dtype=np.uint8)
>>> result = pad_mask_to_min_size(large_mask, (256, 256), fill_value=0)
>>> print(result.shape)
(300, 300)
Notes
-----
Padding is applied to the right and bottom only, preserving the spatial
position of the original mask content in the top-left corner.
"""
current_height, current_width = mask.shape
min_height, min_width = min_size
# Check if padding is needed
if current_height >= min_height and current_width >= min_width:
return mask
# Calculate padding amounts (pad only to the right and bottom)
pad_height = max(0, min_height - current_height)
pad_width = max(0, min_width - current_width)
# Apply padding: ((top, bottom), (left, right))
padded_mask = np.pad(
mask,
pad_width=((0, pad_height), (0, pad_width)),
mode="constant",
constant_values=fill_value,
)
return padded_mask