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/__init__.py +39 -0
- patchcraft/cache.py +247 -0
- patchcraft/extract.py +116 -0
- patchcraft/geometry.py +290 -0
- patchcraft/metrics.py +151 -0
- patchcraft/pair.py +161 -0
- patchcraft/py.typed +0 -0
- patchcraft/reconstruct.py +118 -0
- patchcraft/resize.py +189 -0
- patchcraft/stitch.py +215 -0
- patchcraft-0.2.0.dist-info/METADATA +266 -0
- patchcraft-0.2.0.dist-info/RECORD +14 -0
- patchcraft-0.2.0.dist-info/WHEEL +4 -0
- patchcraft-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|