patchcraft 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
patchcraft/metrics.py ADDED
@@ -0,0 +1,151 @@
1
+ """Pixel-level metrics between patches (or between any same-shape tensors).
2
+
3
+ Three pure functions, no state, no allocation beyond the diff itself. Lives
4
+ in PatchCraft because every consumer that uses ``extract`` + ``reconstruct``
5
+ or ``pair`` ends up reinventing the same MSE/PSNR per patch — bundling them
6
+ here saves consumers from inventing slightly-different reductions and gives
7
+ the test suite a stable comparison surface.
8
+
9
+ Out of scope: SSIM, MS-SSIM, LPIPS, perceptual losses. Those depend on
10
+ windowing schemes or pre-trained networks; pytorch-msssim and lpips are
11
+ mature standalone packages.
12
+
13
+ Contract: docs/THEORY.md §1.6 and §9.7.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import math
18
+
19
+ import torch
20
+
21
+ __all__ = ["patch_metrics", "per_patch_mse", "per_patch_psnr"]
22
+
23
+
24
+ def _check_pair(a: torch.Tensor, b: torch.Tensor) -> None:
25
+ if not isinstance(a, torch.Tensor):
26
+ raise TypeError(f"a must be torch.Tensor, got {type(a).__name__}")
27
+ if not isinstance(b, torch.Tensor):
28
+ raise TypeError(f"b must be torch.Tensor, got {type(b).__name__}")
29
+ if a.shape != b.shape:
30
+ raise ValueError(
31
+ f"shape mismatch: a.shape={tuple(a.shape)}, b.shape={tuple(b.shape)}"
32
+ )
33
+ if a.dtype != b.dtype:
34
+ raise ValueError(
35
+ f"dtype mismatch: a.dtype={a.dtype}, b.dtype={b.dtype}"
36
+ )
37
+ if a.device != b.device:
38
+ raise ValueError(
39
+ f"device mismatch: a.device={a.device}, b.device={b.device}"
40
+ )
41
+
42
+
43
+ def _check_max_value(max_value: float) -> float:
44
+ if not isinstance(max_value, (int, float)) or isinstance(max_value, bool):
45
+ raise ValueError(
46
+ f"max_value must be a positive number, got {max_value!r}"
47
+ )
48
+ if not math.isfinite(max_value) or max_value <= 0:
49
+ raise ValueError(
50
+ f"max_value must be finite and positive, got {max_value!r}"
51
+ )
52
+ return float(max_value)
53
+
54
+
55
+ def patch_metrics(
56
+ a: torch.Tensor,
57
+ b: torch.Tensor,
58
+ *,
59
+ max_value: float = 1.0,
60
+ ) -> dict[str, float]:
61
+ """Pixel-level metrics between two same-shape tensors.
62
+
63
+ Computes over the full tensor (reduces every axis). Works on single
64
+ patches ``(C, h, w)``, patch stacks ``(L, C, h, w)``, paired patches
65
+ of either shape — anything as long as ``a.shape == b.shape``.
66
+
67
+ Internal accumulation promotes to ``float64`` for stability, regardless
68
+ of input dtype. Returns plain Python floats so the dict can be
69
+ JSON-serialized.
70
+
71
+ Parameters
72
+ ----------
73
+ a, b
74
+ Same shape, same dtype, same device.
75
+ max_value
76
+ Dynamic range of the signal (``1.0`` for normalized
77
+ ``float`` in ``[0, 1]``, ``255`` for byte-scaled). Used only for PSNR.
78
+
79
+ Returns
80
+ -------
81
+ dict
82
+ ``{"mae", "mse", "max_abs", "psnr_db"}``. ``psnr_db`` is
83
+ ``+inf`` when ``a == b`` exactly.
84
+
85
+ Raises
86
+ ------
87
+ TypeError, ValueError
88
+ On non-tensor input, shape/dtype/device mismatch, or non-positive
89
+ ``max_value``.
90
+ """
91
+ _check_pair(a, b)
92
+ mv = _check_max_value(max_value)
93
+
94
+ a64 = a.to(torch.float64) if a.dtype != torch.float64 else a
95
+ b64 = b.to(torch.float64) if b.dtype != torch.float64 else b
96
+ diff = a64 - b64
97
+ abs_diff = diff.abs()
98
+ mse = (diff * diff).mean().item()
99
+ psnr_db = float("inf") if mse == 0.0 else 10.0 * math.log10(mv * mv / mse)
100
+ return {
101
+ "mae": abs_diff.mean().item(),
102
+ "mse": mse,
103
+ "max_abs": abs_diff.max().item(),
104
+ "psnr_db": psnr_db,
105
+ }
106
+
107
+
108
+ def per_patch_mse(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
109
+ """Return a ``(L,)`` tensor of MSE values, one per patch in ``(L, C, h, w)``.
110
+
111
+ Useful for ranking patches by reconstruction error after a model pass
112
+ or after a lossy round-trip. Reduction is over ``C, h, w``; the leading
113
+ axis is preserved.
114
+
115
+ Raises ``ValueError`` if either input is not 4-D or shapes differ.
116
+ """
117
+ _check_pair(a, b)
118
+ if a.ndim != 4:
119
+ raise ValueError(
120
+ f"per_patch_mse expects 4-D tensors (L, C, h, w), got ndim={a.ndim}"
121
+ )
122
+ diff = a - b
123
+ return (diff * diff).mean(dim=(1, 2, 3))
124
+
125
+
126
+ def per_patch_psnr(
127
+ a: torch.Tensor,
128
+ b: torch.Tensor,
129
+ *,
130
+ max_value: float = 1.0,
131
+ ) -> torch.Tensor:
132
+ """Return a ``(L,)`` tensor of PSNR values (dB), one per patch.
133
+
134
+ Identical patches yield ``+inf`` (no clamp tricks — the result is
135
+ mathematically infinite and the caller should treat it as such).
136
+
137
+ Parameters
138
+ ----------
139
+ a, b
140
+ ``(L, C, h, w)`` tensors with identical shape, dtype, device.
141
+ max_value
142
+ Signal dynamic range; see :func:`patch_metrics`.
143
+ """
144
+ mv = _check_max_value(max_value)
145
+ mse = per_patch_mse(a, b)
146
+ finfo = torch.finfo(mse.dtype) if mse.is_floating_point() else None
147
+ tiny = finfo.tiny if finfo is not None else 1e-12
148
+ mse_safe = mse.clamp_min(tiny)
149
+ psnr = 10.0 * torch.log10((mv * mv) / mse_safe)
150
+ inf = torch.full_like(mse, float("inf"))
151
+ return torch.where(mse == 0, inf, psnr)
patchcraft/pair.py ADDED
@@ -0,0 +1,161 @@
1
+ """LR ↔ HR patch pairing.
2
+
3
+ Given a low-resolution image, a high-resolution image, and an integer scale
4
+ factor, produce patches on both sides that correspond pixel-region for
5
+ pixel-region (patch ``k`` on each side covers the same image area, at
6
+ different resolutions).
7
+
8
+ Contract: docs/THEORY.md §3 and §9.3.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ import torch
15
+
16
+ from patchcraft.extract import _as_pair, extract
17
+
18
+ __all__ = ["PatchMeta", "PatchPair", "pair"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class PatchMeta:
23
+ """Metadata for a single LR/HR patch correspondence.
24
+
25
+ Lives on CPU (never moves to GPU regardless of where the patches live).
26
+ Identifies *which* patch in the grid — coordinates are in LR pixel space;
27
+ multiply ``row`` and ``col`` by ``scale_factor`` to get HR coordinates.
28
+ """
29
+
30
+ patch_index: int
31
+ row: int
32
+ col: int
33
+ lr_patch_size: tuple[int, int]
34
+ hr_patch_size: tuple[int, int]
35
+ image_id: str | None = None
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class PatchPair:
40
+ """Result of ``pair()``: LR and HR patch tensors plus per-patch metadata."""
41
+
42
+ lr_patches: torch.Tensor # (L, C, ph_lr, pw_lr)
43
+ hr_patches: torch.Tensor # (L, C, ph_hr, pw_hr)
44
+ metas: tuple[PatchMeta, ...]
45
+
46
+ def __len__(self) -> int:
47
+ return int(self.lr_patches.shape[0])
48
+
49
+
50
+ def pair(
51
+ lr_image: torch.Tensor,
52
+ hr_image: torch.Tensor,
53
+ lr_patch_size: int | tuple[int, int],
54
+ scale_factor: int,
55
+ stride: int | tuple[int, int],
56
+ *,
57
+ image_id: str | None = None,
58
+ ) -> PatchPair:
59
+ """Extract aligned LR/HR patch pairs.
60
+
61
+ Both images are ``(C, H, W)``. ``hr_image.shape`` must equal
62
+ ``(C, scale_factor * H_lr, scale_factor * W_lr)``. HR patch size and HR
63
+ stride are derived as ``scale_factor * lr_*``; dilation is fixed at 1.
64
+
65
+ Patch ``k`` on the LR side has its top-left at LR pixel
66
+ ``(row * sh_lr, col * sw_lr)``; the corresponding HR patch covers the
67
+ same image region at ``scale_factor`` times the resolution.
68
+
69
+ Returns a ``PatchPair`` with:
70
+ - ``lr_patches``: ``Tensor[L, C, ph_lr, pw_lr]`` (from `extract`).
71
+ - ``hr_patches``: ``Tensor[L, C, ph_hr, pw_hr]``.
72
+ - ``metas``: tuple of ``L`` :class:`PatchMeta` (CPU only).
73
+
74
+ Raises ``ValueError`` on any of the conditions listed in §9.3: non-int
75
+ or non-positive ``scale_factor``; HR shape that does not match
76
+ ``scale_factor * LR shape``; channel mismatch between LR and HR; LR or
77
+ HR not 3D; non-positive ``lr_patch_size`` or ``stride``.
78
+
79
+ LR and HR are expected to share the same dtype and device; mismatch is
80
+ rejected (caller normalizes upstream).
81
+ """
82
+ if not isinstance(lr_image, torch.Tensor):
83
+ raise TypeError(
84
+ f"lr_image must be torch.Tensor, got {type(lr_image).__name__}"
85
+ )
86
+ if not isinstance(hr_image, torch.Tensor):
87
+ raise TypeError(
88
+ f"hr_image must be torch.Tensor, got {type(hr_image).__name__}"
89
+ )
90
+ if lr_image.ndim != 3:
91
+ raise ValueError(f"lr_image must have ndim==3, got ndim={lr_image.ndim}")
92
+ if hr_image.ndim != 3:
93
+ raise ValueError(f"hr_image must have ndim==3, got ndim={hr_image.ndim}")
94
+
95
+ if (
96
+ not isinstance(scale_factor, int)
97
+ or isinstance(scale_factor, bool)
98
+ or scale_factor <= 0
99
+ ):
100
+ raise ValueError(
101
+ f"scale_factor must be a positive int, got {scale_factor!r}"
102
+ )
103
+
104
+ c_lr, h_lr, w_lr = lr_image.shape
105
+ c_hr, h_hr, w_hr = hr_image.shape
106
+
107
+ if c_lr != c_hr:
108
+ raise ValueError(
109
+ f"channel mismatch: lr_image has C={c_lr}, hr_image has C={c_hr}"
110
+ )
111
+ if lr_image.dtype != hr_image.dtype:
112
+ raise ValueError(
113
+ f"dtype mismatch: lr_image is {lr_image.dtype}, hr_image is {hr_image.dtype}"
114
+ )
115
+ if lr_image.device != hr_image.device:
116
+ raise ValueError(
117
+ f"device mismatch: lr_image on {lr_image.device}, hr_image on {hr_image.device}"
118
+ )
119
+
120
+ if h_hr != scale_factor * h_lr or w_hr != scale_factor * w_lr:
121
+ raise ValueError(
122
+ f"hr_image shape {hr_image.shape} does not match "
123
+ f"scale_factor={scale_factor} times lr_image shape {lr_image.shape}; "
124
+ f"expected hr shape ({c_lr}, {scale_factor * h_lr}, {scale_factor * w_lr})"
125
+ )
126
+
127
+ ph_lr, pw_lr = _as_pair(lr_patch_size, "lr_patch_size")
128
+ sh_lr, sw_lr = _as_pair(stride, "stride")
129
+
130
+ ph_hr, pw_hr = ph_lr * scale_factor, pw_lr * scale_factor
131
+ sh_hr, sw_hr = sh_lr * scale_factor, sw_lr * scale_factor
132
+
133
+ lr_patches = extract(
134
+ lr_image, patch_size=(ph_lr, pw_lr), stride=(sh_lr, sw_lr)
135
+ )
136
+ hr_patches = extract(
137
+ hr_image, patch_size=(ph_hr, pw_hr), stride=(sh_hr, sw_hr)
138
+ )
139
+
140
+ # Geometry is identical by construction (integer scale), so counts match.
141
+ n_patches = int(lr_patches.shape[0])
142
+ if hr_patches.shape[0] != n_patches: # defensive — shouldn't happen
143
+ raise RuntimeError(
144
+ f"internal: lr and hr patch counts diverged "
145
+ f"({n_patches} vs {hr_patches.shape[0]}); please file a bug"
146
+ )
147
+
148
+ num_w_lr = (w_lr - pw_lr) // sw_lr + 1 if n_patches > 0 else 0
149
+ metas = tuple(
150
+ PatchMeta(
151
+ patch_index=k,
152
+ row=(k // num_w_lr) * sh_lr if num_w_lr else 0,
153
+ col=(k % num_w_lr) * sw_lr if num_w_lr else 0,
154
+ lr_patch_size=(ph_lr, pw_lr),
155
+ hr_patch_size=(ph_hr, pw_hr),
156
+ image_id=image_id,
157
+ )
158
+ for k in range(n_patches)
159
+ )
160
+
161
+ return PatchPair(lr_patches=lr_patches, hr_patches=hr_patches, metas=metas)
patchcraft/py.typed ADDED
File without changes
@@ -0,0 +1,118 @@
1
+ """Reconstruction of an image from its patches via torch.nn.functional.fold.
2
+
3
+ Contract: docs/THEORY.md §2 and §9.2.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import torch
8
+ import torch.nn.functional as F # noqa: N812 (torch convention)
9
+
10
+ from patchcraft.extract import _as_pair
11
+
12
+ __all__ = ["reconstruct"]
13
+
14
+
15
+ def reconstruct(
16
+ patches: torch.Tensor,
17
+ image_shape: tuple[int, int, int],
18
+ stride: int | tuple[int, int],
19
+ dilation: int | tuple[int, int] = 1,
20
+ ) -> torch.Tensor:
21
+ """Inverse of `extract`: rebuild a ``(C, H, W)`` image from ``(L, C, ph, pw)``.
22
+
23
+ Uses ``F.fold`` plus an overlap count map. Bit-exact round-trip when
24
+ ``stride == patch_size`` (each pixel covered exactly once). For overlap
25
+ (``stride < patch_size``), each pixel's reconstructed value is the average
26
+ of all patches covering it — same as the original when patches came from
27
+ ``extract`` unmodified.
28
+
29
+ Rejects (per §9.2): ``dilation != 1``; ``stride > patch_size`` in any axis
30
+ (partial coverage would synthesize pixel values, which PatchCraft refuses);
31
+ ``image_shape`` inconsistent with the patch grid (channels mismatch or
32
+ ``L`` does not match the geometry); ``patches.ndim != 4``.
33
+
34
+ Dtype and device of ``patches`` are preserved. For ``float16``, precision
35
+ is degraded by the divide-by-count-map step; promote to ``float32`` before
36
+ calling if exactness matters.
37
+ """
38
+ if not isinstance(patches, torch.Tensor):
39
+ raise TypeError(
40
+ f"patches must be torch.Tensor, got {type(patches).__name__}"
41
+ )
42
+ if patches.ndim != 4:
43
+ raise ValueError(
44
+ f"patches must have ndim==4 (L, C, ph, pw), got ndim={patches.ndim}"
45
+ )
46
+
47
+ n_patches, c, ph, pw = patches.shape
48
+
49
+ if not (isinstance(image_shape, tuple) and len(image_shape) == 3):
50
+ raise ValueError(
51
+ f"image_shape must be a 3-tuple (C, H, W), got {image_shape!r}"
52
+ )
53
+ for axis_name, val in zip(("C", "H", "W"), image_shape, strict=True):
54
+ if not isinstance(val, int) or isinstance(val, bool) or val <= 0:
55
+ raise ValueError(
56
+ f"image_shape[{axis_name}] must be a positive int, got {val!r}"
57
+ )
58
+ target_c, h, w = image_shape
59
+ if target_c != c:
60
+ raise ValueError(
61
+ f"image_shape channels={target_c} does not match patches channel count {c}"
62
+ )
63
+
64
+ sh, sw = _as_pair(stride, "stride")
65
+ dh, dw = _as_pair(dilation, "dilation")
66
+
67
+ if dh != 1 or dw != 1:
68
+ raise ValueError(
69
+ f"reconstruct requires dilation==1, got dilation=({dh}, {dw}). "
70
+ "Patches extracted with dilation > 1 cannot round-trip — consume them as features."
71
+ )
72
+ if sh > ph or sw > pw:
73
+ raise ValueError(
74
+ f"reconstruct forbids stride > patch_size (partial coverage forbidden), "
75
+ f"got stride=({sh}, {sw}) and patch_size=({ph}, {pw})."
76
+ )
77
+
78
+ num_h = (h - ph) // sh + 1
79
+ num_w = (w - pw) // sw + 1
80
+ if num_h <= 0 or num_w <= 0:
81
+ raise ValueError(
82
+ f"image_shape={image_shape} too small for patch_size=({ph}, {pw}) "
83
+ f"and stride=({sh}, {sw})"
84
+ )
85
+ expected_n_patches = num_h * num_w
86
+ if n_patches != expected_n_patches:
87
+ raise ValueError(
88
+ f"patches.shape[0]={n_patches} inconsistent with grid implied by "
89
+ f"image_shape={image_shape}, patch_size=({ph}, {pw}), "
90
+ f"stride=({sh}, {sw}): expected L={expected_n_patches} "
91
+ f"(num_h={num_h}, num_w={num_w})."
92
+ )
93
+
94
+ # (L, C, ph, pw) -> (1, C*ph*pw, L), the layout F.fold expects.
95
+ patches_flat = (
96
+ patches.permute(1, 2, 3, 0).reshape(c * ph * pw, n_patches).unsqueeze(0)
97
+ )
98
+ folded = F.fold(
99
+ patches_flat,
100
+ output_size=(h, w),
101
+ kernel_size=(ph, pw),
102
+ stride=(sh, sw),
103
+ )
104
+
105
+ # Count map: same fold geometry but 1 "channel" — broadcasts across C in division.
106
+ ones = torch.ones(
107
+ 1, ph * pw, n_patches, dtype=patches.dtype, device=patches.device
108
+ )
109
+ count = F.fold(
110
+ ones,
111
+ output_size=(h, w),
112
+ kernel_size=(ph, pw),
113
+ stride=(sh, sw),
114
+ )
115
+
116
+ # clamp(min=1e-6) absorbs float noise on covered pixels; geometry validation
117
+ # above guarantees there are no uncovered pixels (count > 0 everywhere).
118
+ return (folded / count.clamp(min=1e-6))[0]
patchcraft/resize.py ADDED
@@ -0,0 +1,189 @@
1
+ """Image resizing with PIL or torch backends.
2
+
3
+ Output type matches input: PIL → PIL, Tensor → Tensor. Cross-backend
4
+ conversions go through a normalized float32 [0, 1] intermediate (with a
5
+ uint8 hop into PIL because PIL's standard modes are byte-typed).
6
+
7
+ Contract: docs/THEORY.md §5 and §9.4.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, Any, Literal
12
+
13
+ import numpy as np
14
+ import torch
15
+ import torch.nn.functional as F # noqa: N812 (torch convention)
16
+
17
+ if TYPE_CHECKING:
18
+ from PIL.Image import Image as PILImage
19
+
20
+ __all__ = ["resize"]
21
+
22
+
23
+ _PIL_RESAMPLE_NAMES = {
24
+ "nearest", "bilinear", "bicubic", "lanczos", "box", "hamming",
25
+ }
26
+ _TORCH_RESAMPLE_NAMES = {
27
+ "nearest", "bilinear", "bicubic", "area", "nearest-exact",
28
+ }
29
+
30
+
31
+ def _validate_target_size(target_size: object) -> tuple[int, int]:
32
+ if not (isinstance(target_size, tuple) and len(target_size) == 2):
33
+ raise ValueError(
34
+ f"target_size must be a 2-tuple (H, W), got {target_size!r}"
35
+ )
36
+ for axis_name, val in zip(("H", "W"), target_size, strict=True):
37
+ if not isinstance(val, int) or isinstance(val, bool) or val <= 0:
38
+ raise ValueError(
39
+ f"target_size[{axis_name}] must be a positive int, got {val!r}"
40
+ )
41
+ return (target_size[0], target_size[1])
42
+
43
+
44
+ def _pil_to_tensor_f32(pil_image: PILImage) -> torch.Tensor:
45
+ """PIL → ``Tensor[C, H, W]`` float32 in [0, 1]. Copy is forced (PIL buffers
46
+ are not safely sharable with torch)."""
47
+ arr = np.asarray(pil_image, dtype=np.float32) / 255.0
48
+ arr = arr[np.newaxis, ...] if arr.ndim == 2 else arr.transpose(2, 0, 1)
49
+ return torch.from_numpy(np.ascontiguousarray(arr))
50
+
51
+
52
+ def _tensor_to_pil_u8(tensor: torch.Tensor) -> PILImage:
53
+ """``Tensor[C, H, W]`` → PIL.Image (uint8 modes: L, RGB, RGBA only)."""
54
+ from PIL import Image
55
+ if tensor.is_floating_point():
56
+ arr = (tensor.clamp(0, 1).cpu().numpy() * 255).round().astype(np.uint8)
57
+ else:
58
+ arr = tensor.cpu().numpy().astype(np.uint8)
59
+ c = arr.shape[0]
60
+ if c == 1:
61
+ return Image.fromarray(arr[0], mode="L")
62
+ if c == 3:
63
+ return Image.fromarray(arr.transpose(1, 2, 0), mode="RGB")
64
+ if c == 4:
65
+ return Image.fromarray(arr.transpose(1, 2, 0), mode="RGBA")
66
+ raise ValueError(
67
+ f"cannot convert tensor with {c} channels to PIL "
68
+ f"(supported: C=1 → L, C=3 → RGB, C=4 → RGBA)"
69
+ )
70
+
71
+
72
+ def _resize_pil(
73
+ pil_image: PILImage,
74
+ target_size: tuple[int, int],
75
+ resample: str | None,
76
+ ) -> PILImage:
77
+ from PIL import Image
78
+ h, w = target_size
79
+ if resample is None:
80
+ pil_resample = Image.Resampling.LANCZOS
81
+ else:
82
+ if not isinstance(resample, str):
83
+ raise ValueError(
84
+ f"resample must be str or None, got {type(resample).__name__}"
85
+ )
86
+ key = resample.lower()
87
+ if key not in _PIL_RESAMPLE_NAMES:
88
+ raise ValueError(
89
+ f"resample {resample!r} not supported by PIL backend; "
90
+ f"valid: {sorted(_PIL_RESAMPLE_NAMES)}"
91
+ )
92
+ pil_resample = Image.Resampling[key.upper()]
93
+ return pil_image.resize((w, h), pil_resample)
94
+
95
+
96
+ def _resize_torch(
97
+ tensor: torch.Tensor,
98
+ target_size: tuple[int, int],
99
+ resample: str | None,
100
+ ) -> torch.Tensor:
101
+ if resample is None:
102
+ mode = "bilinear"
103
+ else:
104
+ if not isinstance(resample, str):
105
+ raise ValueError(
106
+ f"resample must be str or None, got {type(resample).__name__}"
107
+ )
108
+ key = resample.lower()
109
+ if key not in _TORCH_RESAMPLE_NAMES:
110
+ raise ValueError(
111
+ f"resample {resample!r} not supported by torch backend; "
112
+ f"valid: {sorted(_TORCH_RESAMPLE_NAMES)}"
113
+ )
114
+ mode = key
115
+
116
+ original_dtype = tensor.dtype
117
+ x = tensor.unsqueeze(0)
118
+ if mode in {"bilinear", "bicubic"} and not x.is_floating_point():
119
+ x = x.to(torch.float32)
120
+
121
+ kwargs: dict[str, Any] = {"size": target_size, "mode": mode}
122
+ if mode in {"bilinear", "bicubic"}:
123
+ kwargs["align_corners"] = False
124
+ out = F.interpolate(x, **kwargs)
125
+ return out[0].to(original_dtype)
126
+
127
+
128
+ def resize(
129
+ image: torch.Tensor | PILImage,
130
+ target_size: tuple[int, int],
131
+ backend: Literal["pil", "torch"] = "pil",
132
+ resample: str | None = None,
133
+ ) -> torch.Tensor | PILImage:
134
+ """Resize a single image, preserving input type.
135
+
136
+ ``image`` is a ``PIL.Image`` or ``Tensor[C, H, W]``. ``target_size`` is
137
+ ``(H, W)``. ``backend`` selects the resize algorithm family
138
+ (``"pil"`` → ``PIL.Image.resize``; ``"torch"`` → ``F.interpolate``).
139
+ Cross-backend conversions go through a normalized float32 [0, 1]
140
+ intermediate. CUDA tensors are only accepted with ``backend="torch"``.
141
+
142
+ ``resample=None`` picks each backend's default: LANCZOS for ``"pil"``,
143
+ bilinear for ``"torch"``. The accepted resample strings differ between
144
+ backends; an unsupported choice raises ``ValueError``.
145
+
146
+ Rejects (per §9.4): non-2-tuple or non-positive ``target_size``;
147
+ ``backend`` not in ``{"pil", "torch"}``; CUDA tensor with
148
+ ``backend="pil"``; unsupported ``resample`` for the chosen backend;
149
+ PIL tensor conversion of an unsupported channel count.
150
+ """
151
+ target_size = _validate_target_size(target_size)
152
+ if backend not in {"pil", "torch"}:
153
+ raise ValueError(f"backend must be 'pil' or 'torch', got {backend!r}")
154
+
155
+ if isinstance(image, torch.Tensor):
156
+ if image.ndim != 3:
157
+ raise ValueError(
158
+ f"tensor image must have ndim==3 (C, H, W), got ndim={image.ndim}"
159
+ )
160
+ if backend == "pil":
161
+ if image.device.type != "cpu":
162
+ raise ValueError(
163
+ f"backend='pil' cannot accept tensors on {image.device}; "
164
+ "move to CPU explicitly with .cpu() first"
165
+ )
166
+ original_dtype = image.dtype
167
+ pil_in = _tensor_to_pil_u8(image)
168
+ pil_out = _resize_pil(pil_in, target_size, resample)
169
+ tensor_out = _pil_to_tensor_f32(pil_out)
170
+ if not torch.empty(0, dtype=original_dtype).is_floating_point():
171
+ tensor_out = (tensor_out * 255).round()
172
+ return tensor_out.to(original_dtype)
173
+ return _resize_torch(image, target_size, resample)
174
+
175
+ # PIL branch.
176
+ try:
177
+ from PIL.Image import Image as PILImageCls
178
+ except ImportError as exc: # pragma: no cover — pillow is a runtime dep
179
+ raise ImportError("Pillow is required for resize") from exc
180
+ if not isinstance(image, PILImageCls):
181
+ raise TypeError(
182
+ f"image must be torch.Tensor or PIL.Image, got {type(image).__name__}"
183
+ )
184
+ if backend == "pil":
185
+ return _resize_pil(image, target_size, resample)
186
+ # PIL + backend == "torch"
187
+ tensor = _pil_to_tensor_f32(image)
188
+ resized = _resize_torch(tensor, target_size, resample)
189
+ return _tensor_to_pil_u8(resized)