patchcraft 0.5.7__tar.gz → 0.6.0__tar.gz
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-0.5.7 → patchcraft-0.6.0}/CHANGELOG.md +48 -1
- {patchcraft-0.5.7 → patchcraft-0.6.0}/CITATION.cff +2 -2
- {patchcraft-0.5.7 → patchcraft-0.6.0}/CONTRIBUTING.md +5 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/PKG-INFO +1 -1
- {patchcraft-0.5.7 → patchcraft-0.6.0}/STATUS.md +15 -7
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ADR/0004-precision-and-effort-parameters.md +10 -7
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/GUIDE.md +6 -6
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/THEORY.md +6 -4
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/USAGE.md +17 -2
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/_version.py +3 -3
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/resize.py +35 -3
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/stitch.py +20 -3
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/PKG-INFO +1 -1
- patchcraft-0.6.0/src/patchcraft.egg-info/scm_version.json +8 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_public_api.py +1 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_resize.py +129 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_stitch.py +30 -10
- patchcraft-0.5.7/src/patchcraft.egg-info/scm_version.json +0 -8
- {patchcraft-0.5.7 → patchcraft-0.6.0}/.gitignore +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/.pre-commit-config.yaml +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/.python-version +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/AGENTS.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/CODE_OF_CONDUCT.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/LICENSE +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/MANIFEST.in +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/MAP.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/README.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/README.pt-BR.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/README.pypi.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/SECURITY.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/accel/Cargo.lock +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/accel/Cargo.toml +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/accel/README.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/accel/src/kernel.rs +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/accel/src/lib.rs +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ADR/0001-patch-extraction-api.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ADR/0002-patchify-transform.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ADR/0003-reversibility-classes.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ADR/0005-border-policy.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/AUXILIARY.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/FOCO-1.0.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/PERFORMANCE.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/ROADMAP.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/SCOPE.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/STUDIES/2026-08-04-patch-techniques.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/STUDIES/2026-09-03-precision-measurements.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/STUDIES/2026-09-03-strata-conformity.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/STUDIES/2026-10-10-border-policy.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/design/2026-09-01-fase1-bugs-performance.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/design/2026-09-01-fase2-acelerador.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/docs/design/2026-09-01-fase3-predicado.md +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/pyproject.toml +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/setup.cfg +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/setup.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/__init__.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/_accel.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/_foldgeom.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/cache.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/extract.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/geometry.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/metrics.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/pair.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/py.typed +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft/reconstruct.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/SOURCES.txt +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/dependency_links.txt +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/requires.txt +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/scm_file_list.json +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/src/patchcraft.egg-info/top_level.txt +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/__init__.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/_datasets.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/_rng.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/conftest.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_accel.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_cache.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_citation.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_datasets_helper.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_docs_usage.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_exactness.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_extract.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_geometry.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_import.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_integer.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_metrics.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_operator.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_pair.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_reconstruct.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_reference.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_rng.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tests/test_vmap.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tools/benchmark.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tools/check_dist.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tools/compare_border_strategies.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tools/compare_exactness_rules.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/tools/make_outreach_figures.py +0 -0
- {patchcraft-0.5.7 → patchcraft-0.6.0}/uv.lock +0 -0
|
@@ -6,6 +6,52 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.6.0] - 2026-10-11
|
|
10
|
+
|
|
11
|
+
A new `0.y` because `resize` gains a parameter, `antialias`, whose default
|
|
12
|
+
changes no call. With it comes a faster `stitch` under the uniform window,
|
|
13
|
+
bit-identical to the code it replaces. No returned value of an existing call
|
|
14
|
+
changes. The full suite passes on both code paths at the declared floor and
|
|
15
|
+
at the newest releases, and the 126,736-geometry sweep passes on both paths.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **`stitch` with the uniform window hands an overlapping grid to
|
|
20
|
+
`reconstruct`.** Every weight is 1, so the weighted fold is the plain fold
|
|
21
|
+
in the same order and the separable denominator is the count map, which is
|
|
22
|
+
`reconstruct`'s arithmetic without the multiply by ones and the two window
|
|
23
|
+
folds. Compared against the previous code through integer views: 996
|
|
24
|
+
geometries of the sampled space, four float dtypes, signed zeros,
|
|
25
|
+
infinities, NaN payloads and subnormals, outputs and gradients, both code
|
|
26
|
+
paths, 0 differences. Against the previous code, interleaved and taking the
|
|
27
|
+
median of 9 to 41 runs: 1.45 and 1.75 times faster on the pure path at
|
|
28
|
+
3x512x512 and 3x1024x1024 and 1.06 at 3x2048x2048, where the fold dominates,
|
|
29
|
+
and 1.31, 1.17 and 1.10 times with the accelerator. A non-overlapping grid
|
|
30
|
+
takes the same path as before. The test that compared the two with
|
|
31
|
+
`allclose` now compares bits, counts of 3 included.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **`resize(..., antialias=None)`**, keyword-only, with the name, values and
|
|
36
|
+
meaning of torchvision v2's `resize` and the default ADR 0004 recorded. With
|
|
37
|
+
`True` the torch backend filters before a bilinear or bicubic downsample, as
|
|
38
|
+
PIL always does. On the tests' noise image reduced by 4 it lands within
|
|
39
|
+
1/255 of the PIL backend, where the unfiltered path is more than 20/255
|
|
40
|
+
away, and it passes a third of the noise's standard deviation. For float
|
|
41
|
+
tensors the output equals torchvision v2's `resize(antialias=True)` bit for
|
|
42
|
+
bit; half precision is filtered in `float32` and cast back, as torchvision
|
|
43
|
+
does, because torch has no antialiased half-precision kernel. Integer
|
|
44
|
+
tensors are rounded and clamped as on every other path, where torchvision
|
|
45
|
+
rounds `uint8` in its own fixed-point kernel, so the two can differ there.
|
|
46
|
+
`None` and `False` leave the call to `F.interpolate` exactly as it was, so
|
|
47
|
+
no existing call changes, and the flag is ignored where it means nothing,
|
|
48
|
+
the other torch modes and the PIL backend, as torchvision ignores it. The
|
|
49
|
+
filtered path uses PIL's bicubic kernel (`a = -0.5`) where the plain one
|
|
50
|
+
uses torch's (`a = -0.75`), so it changes bicubic enlargement too, and the
|
|
51
|
+
docs say it is for reducing. With the flag, resizing to the same size
|
|
52
|
+
returns the input bit for bit. Removing the flag from either call site, or
|
|
53
|
+
the half-precision promotion, fails the new tests.
|
|
54
|
+
|
|
9
55
|
## [0.5.7] - 2026-10-10
|
|
10
56
|
|
|
11
57
|
Integer images, end to end. `extract` takes every dtype at every dilation,
|
|
@@ -1207,7 +1253,8 @@ First public release. Public API stable; signatures will only change in 1.x.
|
|
|
1207
1253
|
- [`README.md`](README.md) covers installation, the car-vs-track metaphor,
|
|
1208
1254
|
validation lab.
|
|
1209
1255
|
|
|
1210
|
-
[Unreleased]: https://github.com/LeoPR/PatchCraft/compare/v0.
|
|
1256
|
+
[Unreleased]: https://github.com/LeoPR/PatchCraft/compare/v0.6.0...HEAD
|
|
1257
|
+
[0.6.0]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.6.0
|
|
1211
1258
|
[0.5.7]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.7
|
|
1212
1259
|
[0.5.6]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.6
|
|
1213
1260
|
[0.5.5]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.5
|
|
@@ -6,8 +6,8 @@ authors:
|
|
|
6
6
|
- given-names: Leonardo
|
|
7
7
|
family-names: Marques de Souza
|
|
8
8
|
email: leonardo.marques.souza@gmail.com
|
|
9
|
-
version: "0.
|
|
10
|
-
date-released: "2026-10-
|
|
9
|
+
version: "0.6.0"
|
|
10
|
+
date-released: "2026-10-11"
|
|
11
11
|
repository-code: "https://github.com/LeoPR/PatchCraft"
|
|
12
12
|
license: MIT
|
|
13
13
|
abstract: >
|
|
@@ -275,6 +275,11 @@ moved, or whether the implementation stopped violating it**:
|
|
|
275
275
|
| Documentation, tests, CI, packaging or tooling only | `0.y.Z` | Still a release if it is published, still no break |
|
|
276
276
|
| Nothing published | nothing | Accumulate under `[Unreleased]` |
|
|
277
277
|
|
|
278
|
+
A new keyword-only parameter with a default breaks no call, but it moves a
|
|
279
|
+
signature, so it takes the first row and not the third: 0.6.0 is the example,
|
|
280
|
+
where `resize` gained `antialias`. The third row is for new behaviour behind
|
|
281
|
+
an unchanged signature, such as 0.5.7 accepting integer images.
|
|
282
|
+
|
|
278
283
|
The project's own history sorts cleanly under that rule, and does not sort
|
|
279
284
|
under any rule keyed on whether values changed:
|
|
280
285
|
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
## Where the project is
|
|
8
8
|
|
|
9
|
-
**0.
|
|
9
|
+
**0.6.0 is on PyPI**, published through Trusted Publishing on a tag push: one
|
|
10
10
|
sdist, five `cp312-abi3` platform wheels carrying the Rust accelerator, and one
|
|
11
11
|
universal `py3-none-any` wheel. There is no extra to enable and no second
|
|
12
12
|
package.
|
|
13
13
|
|
|
14
|
-
**
|
|
14
|
+
**2024 tests pass** under `pytest -m "not gpu"`, with 32 skipped and 5
|
|
15
15
|
deselected, plus two full-sweep gates behind `PATCHCRAFT_SWEEP_FULL=1`
|
|
16
16
|
that enumerate all 126,736 legal geometries. CI is green on
|
|
17
17
|
{Ubuntu, Windows} x {3.12, 3.13, 3.14} on the pure path, plus a two-OS job that
|
|
@@ -24,7 +24,14 @@ pillow 10.1, Rust 1.82) that runs it on both paths.
|
|
|
24
24
|
|
|
25
25
|
## What is in flight
|
|
26
26
|
|
|
27
|
-
**Nothing unreleased.** 0.
|
|
27
|
+
**Nothing unreleased.** 0.6.0 added `resize(..., antialias=None)`,
|
|
28
|
+
keyword-only with the default that changes no call, and made `stitch` with
|
|
29
|
+
the uniform window hand an overlapping grid to `reconstruct`, bit-identical and
|
|
30
|
+
1.45 to 1.75 times faster on the pure path at 512 and 1024. The new parameter
|
|
31
|
+
is why it is a new `0.y`, by the README's rule and the first row of
|
|
32
|
+
CONTRIBUTING's table.
|
|
33
|
+
|
|
34
|
+
0.5.7 brought integer images: `extract` takes every
|
|
28
35
|
dtype, and `reconstruct` takes the five integer dtypes and returns them exactly
|
|
29
36
|
in every legal geometry, with disagreeing patches rounded half to even and no
|
|
30
37
|
intermediate overflowing. They are ADR 0004's steps 1 and 2, shipped together.
|
|
@@ -63,10 +70,11 @@ power-of-two ones, and a `float64` accumulator on all 76. The three-letter
|
|
|
63
70
|
vocabulary its first draft proposed is deferred until a transform needs it.
|
|
64
71
|
|
|
65
72
|
**ADR 0004 is `Proposed`, and asks nothing.** It records the precision and
|
|
66
|
-
effort parameters as a design that is not built; the status reflects
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
`
|
|
73
|
+
effort parameters as a design that is mostly not built; the status reflects
|
|
74
|
+
that `accum_dtype`, `rounding_mode` and the preset are not implemented, not
|
|
75
|
+
that a decision is pending. What it records that did ship: its steps 1 and 2,
|
|
76
|
+
integers through `extract` and `reconstruct` without a knob, in 0.5.7, and its
|
|
77
|
+
third parameter, `resize(..., antialias=None)`, in 0.6.0. The one question it
|
|
70
78
|
appeared to hold, whether to add the knobs before the 1.0 freeze or after,
|
|
71
79
|
rested on the premise that adding one afterwards is expensive. It is not: each
|
|
72
80
|
is a keyword-only argument with a default, so adding one is additive and is a
|
|
@@ -39,13 +39,15 @@ a keyword-only argument with a default is additive, so adding one after 1.0 cost
|
|
|
39
39
|
release and nothing else. With the premise checked and gone, YAGNI, the absence of any
|
|
40
40
|
external consumer, and Strata §9's default verdict all point the same way.
|
|
41
41
|
|
|
42
|
-
**So this ADR asks nothing.** It records a design that is not built, the names precedent
|
|
42
|
+
**So this ADR asks nothing.** It records a design that is mostly not built, the names precedent
|
|
43
43
|
determines, the constraint that would make a preset safe, and the order. It stays `Proposed`
|
|
44
|
-
because
|
|
44
|
+
because two of its three parameters, `accum_dtype` and `rounding_mode`, and the preset are not
|
|
45
|
+
implemented, not because a decision is pending.
|
|
45
46
|
|
|
46
|
-
**What is implemented is the integer path without a knob
|
|
47
|
-
together on 2026-10-10, ahead of the 1.0 the table assigned them to, because the
|
|
48
|
-
them forward
|
|
47
|
+
**What is implemented is the integer path without a knob, and `antialias`:** steps 1 and 2
|
|
48
|
+
below shipped together on 2026-10-10, ahead of the 1.0 the table assigned them to, because the
|
|
49
|
+
owner moved them forward, and `antialias` followed the same day, with the name, values and
|
|
50
|
+
default of section 1. `accum_dtype` and `rounding_mode` do not exist.
|
|
49
51
|
|
|
50
52
|
## 1. The names, determined by precedent
|
|
51
53
|
|
|
@@ -178,8 +180,9 @@ into a `precise` tier would sell the absence of a bug as a quality level. **Step
|
|
|
178
180
|
2026-09-03**, and the three were the `stitch` denominator floor, the `reconstruct`
|
|
179
181
|
single-patch aliasing and the `resize` inbound clamp on a dtype that cannot hold the bound.
|
|
180
182
|
The remaining items named in the audit, the `bfloat16` justification in THEORY 9.2 and the
|
|
181
|
-
resize antialias gap,
|
|
182
|
-
|
|
183
|
+
resize antialias gap, were not defects of the same kind: the first was a documentation
|
|
184
|
+
correction, closed in 0.5.6, and the second a user-facing choice, which `antialias=None` as the
|
|
185
|
+
default made additive when it landed on 2026-10-10. Document, at zero
|
|
183
186
|
cost, the rounding policy already in force. Freeze 1.0 at 20 names with no new parameters.
|
|
184
187
|
Then 1.1 brings steps 1 and 2 together and unrestricted. Then stop and wait for a user:
|
|
185
188
|
only if one appears do the knobs follow, and the preset last of all.
|
|
@@ -668,7 +668,7 @@ The reason is visible in the uniform column, which does not move at all. Uniform
|
|
|
668
668
|
### The three windows, and what each costs
|
|
669
669
|
|
|
670
670
|
- **`"uniform"`** is the default. Every covering patch contributes equally, which is exactly `reconstruct`'s arithmetic, and it puts the whole disagreement on the grid lines.
|
|
671
|
-
- **`"hann"`** is the strong seam suppressor. On an overlapping grid
|
|
671
|
+
- **`"hann"`** is the strong seam suppressor. On an overlapping grid hann and gaussian cost the same, since the fold dominates, while uniform costs what `reconstruct` costs, because it is `reconstruct`'s arithmetic; where `stride == patch_size` uniform skips the fold altogether. It is the interior of a longer symmetric Hann window, `hann_window(n + 2, periodic=False)[1:-1]`, so it is strictly positive on every sample and never zeroes a pixel. A plain symmetric window is exactly zero at both endpoints and would zero every pixel covered only by patch edges; [THEORY.md](THEORY.md) §2.5 measures it.
|
|
672
672
|
- **`"gaussian"`** keeps far more weight at the patch edge than hann does, so it suppresses seams less at the larger patch sizes in the sweep, and it wins at the smallest one. THEORY §2.5 states the tradeoff as weaker seam suppression than Hann in exchange for a flatter window.
|
|
673
673
|
|
|
674
674
|
**Hann costs fidelity, and the cost is already in the numbers above.** Measured against the model's own patches, uniform keeps 29.60 dB mean and hann keeps 27.15 dB. Hann is trading exactness for smoothness on purpose, so if what you want back is the model's output rather than a pleasant image, uniform is the honest choice.
|
|
@@ -914,7 +914,7 @@ The table below is the one place that records what each call allocates, which is
|
|
|
914
914
|
| `extract` | `image`, `patch_size`, `stride`, `dilation` | `(L, C, ph, pw)` | the whole patch stack, `L * C * ph * pw` values |
|
|
915
915
|
| `Patchify` | the same geometry, at construction | a callable returning `(L, C, ph, pw)` | nothing beyond the geometry, no cache and no buffer |
|
|
916
916
|
| `reconstruct` | `patches`, `image_shape`, `stride`, `dilation` | `(C, H, W)` | one image plus one count map |
|
|
917
|
-
| `stitch` | the same, plus keyword-only `weight=`, default `"uniform"` | `(C, H, W)` | one image, one weight map, one `(ph, pw)` kernel, and on the pure-torch path a full weighted copy of the patch stack, which is the largest of
|
|
917
|
+
| `stitch` | the same, plus keyword-only `weight=`, default `"uniform"` | `(C, H, W)` | under `"uniform"`, what `reconstruct` allocates; under `"hann"` and `"gaussian"`, one image, one weight map, one `(ph, pw)` kernel, and on the pure-torch path a full weighted copy of the patch stack, which is the largest of these and which the accelerated path skips |
|
|
918
918
|
| `WeightKind` | nothing, it is a `Literal` type alias | `"uniform"`, `"hann"` or `"gaussian"` today; the set is **open**, so a new member may arrive in a `0.y` / minor release and an exhaustive `match` on it should carry a fallback | nothing |
|
|
919
919
|
| `pair` | `lr_image`, `hr_image`, `lr_patch_size`, `scale_factor`, `stride` | `PatchPair` | both patch stacks plus `L` metadata records |
|
|
920
920
|
| `PatchPair`, `PatchMeta` | frozen dataclasses | patches and grid coordinates | metadata stays on CPU, always |
|
|
@@ -924,7 +924,7 @@ The table below is the one place that records what each call allocates, which is
|
|
|
924
924
|
| `paired_tilings` | LR and HR shapes | `list[PairedTilingSpec]` | one spec per legal pair, no pixels |
|
|
925
925
|
| `patch_metrics` | two same-shape tensors | `{mae, mse, max_abs, psnr_db}` | accumulates in float64, returns Python floats |
|
|
926
926
|
| `per_patch_mse`, `per_patch_psnr` | two `(L, C, h, w)` stacks | `(L,)` in float64 | one value per patch |
|
|
927
|
-
| `resize` | one tensor or `PIL.Image`, `target_size`, `backend` | the type it received | one resized image |
|
|
927
|
+
| `resize` | one tensor or `PIL.Image`, `target_size`, `backend`, `antialias` | the type it received | one resized image |
|
|
928
928
|
| `Cache` | `root`, `namespace`, `version` | a content-addressed store | files on disk, zstd payloads when installed |
|
|
929
929
|
| `accel_available` | nothing | `bool`, true when this install has the native extension | nothing beyond a cached import probe on first call |
|
|
930
930
|
|
|
@@ -963,9 +963,9 @@ The LR and HR pairing symbols, which are `pair`, `paired_tilings` and `scale_fac
|
|
|
963
963
|
|
|
964
964
|
## 8. Where this applies today
|
|
965
965
|
|
|
966
|
-
**Before 1.0, and with no external consumer yet.** A real project consuming the published API is the gate [ROADMAP.md](ROADMAP.md) sets for calling the shape settled. Until then a new `0.y` may still change what comes out
|
|
966
|
+
**Before 1.0, and with no external consumer yet.** A real project consuming the published API is the gate [ROADMAP.md](ROADMAP.md) sets for calling the shape settled. Until then a new `0.y` may still change what comes out, so pin the `0.y` you tested against with `~=0.y.0` to take its fixes only.
|
|
967
967
|
|
|
968
|
-
What is verified is this. The full local run of `pytest -m "not gpu"` passes
|
|
968
|
+
What is verified is this. The full local run of `pytest -m "not gpu"` passes 2024 tests, skips 32 cases, and deselects 5 GPU tests, in under a minute on this machine, so run `pytest` yourself for the number in your environment. Of those skips, 30 are geometries that do not cover exactly and 2 are the full 126,736-geometry sweep, which is a local gate you arm with `PATCHCRAFT_SWEEP_FULL=1`. CI runs the same suite plus `ruff check` and `mypy --strict` on Ubuntu and Windows against Python 3.12, 3.13 and 3.14, and all six cells are green. Those six are forced onto the pure-torch path, so a separate two-cell job on Ubuntu and Windows builds the Rust kernel and runs the whole suite through it. Those eight run the newest torch, and one more job runs the whole suite on both paths at the declared floor: Python 3.12, torch 2.6, numpy 1.26 and pillow 10.1, with the kernel built by Rust 1.82. Releases reach PyPI through Trusted Publishing on a tag push. The package is typed and it ships `py.typed`.
|
|
969
969
|
|
|
970
970
|
Where the evidence is thinner, this is what it means for you.
|
|
971
971
|
|
|
@@ -1018,7 +1018,7 @@ Installing from the sdist compiles the accelerator when a Rust toolchain is pres
|
|
|
1018
1018
|
author = {Marques de Souza, Leonardo},
|
|
1019
1019
|
title = {PatchCraft: image patch extraction, reconstruction, pairing
|
|
1020
1020
|
and seam-aware stitching},
|
|
1021
|
-
version = {0.
|
|
1021
|
+
version = {0.6.0}, % check CHANGELOG.md for the release you used
|
|
1022
1022
|
year = {2026},
|
|
1023
1023
|
url = {https://github.com/LeoPR/PatchCraft}
|
|
1024
1024
|
}
|
|
@@ -133,7 +133,7 @@ For *unmodified* patches, every `patch_k(i_k, j_k)` equals `img(x, y)` (definiti
|
|
|
133
133
|
|
|
134
134
|
**Window kernels.**
|
|
135
135
|
|
|
136
|
-
- **`uniform`**: `w ≡ 1`. Numerator becomes the same as `reconstruct`'s fold; denominator becomes the count map.
|
|
136
|
+
- **`uniform`**: `w ≡ 1`. Numerator becomes the same as `reconstruct`'s fold; denominator becomes the count map. On an overlapping grid `stitch` hands the call to `reconstruct`, so the two agree bit for bit. On a non-overlapping one `reconstruct` only copies, while `stitch` returns what the fold would: `+0.0` for a `-0.0`, and a NaN that stays a NaN but may change its bits. Provided so the API is one function with a parameter instead of two functions with a hidden choice.
|
|
137
137
|
- **`hann`**: separable Hann (`outer(hann(ph), hann(pw))`). Center weight is 1, edge weight is small. Strong seam suppression.
|
|
138
138
|
|
|
139
139
|
The 1-D window is the **interior of a longer symmetric Hann window**: `hann_window(n + 2, periodic=False)[1:-1]`, which is strictly positive on every sample, instead of the plain symmetric window `0.5·(1 − cos(2πi/(n−1)))`, which is exactly zero at both endpoints. The zero-endpoint variant was the single largest defect found by the 0.2.0 audit (measured 2026-08-03, fixed in 0.2.1): every pixel whose only covering patches placed it on a patch edge had numerator and denominator both ≈ 0, and the `clamp(min=1e-6)` divisor sent it to zero. Measured on the defective version: at `12×12, ph=pw=4, stride=4`, **108 of 144 pixels** came back zero; at `13×13, ph=pw=4, stride=3`, **105 of 169**, including black bands in the image interior; `patch_size=2` degenerated to an all-zero window and returned an all-black image with no error.
|
|
@@ -228,7 +228,7 @@ LR and HR must agree on dtype and device; mismatch is rejected (caller normalize
|
|
|
228
228
|
- **PIL**: canonical dataset generation (reproducibility is paramount, result stays on CPU anyway).
|
|
229
229
|
- **torch**: in-training augmentation where the tensor is already on GPU and the extra filter divergence does not matter.
|
|
230
230
|
|
|
231
|
-
**Design decision.** One function: `patchcraft.resize(image, target_size, backend="pil", resample=None)`. `target_size` is a `(H, W)` tuple, with no size-spec DSL. `image` may be `PIL.Image` or `Tensor[C, H, W]`. Output type matches input (PIL in → PIL out, Tensor in → Tensor out), regardless of backend. `resample` defaults: `LANCZOS` for PIL, `bilinear` for torch (chosen because bicubic divergence is nastiest to debug; bilinear's difference from PIL's BILINEAR is small). Cross-backend conversions (PIL ↔ Tensor) go through a normalized float32 `[0, 1]` intermediate. No auto-selection: callers pick the backend explicitly and own the consequences.
|
|
231
|
+
**Design decision.** One function: `patchcraft.resize(image, target_size, backend="pil", resample=None, *, antialias=None)`. `target_size` is a `(H, W)` tuple, with no size-spec DSL. `image` may be `PIL.Image` or `Tensor[C, H, W]`. Output type matches input (PIL in → PIL out, Tensor in → Tensor out), regardless of backend. `resample` defaults: `LANCZOS` for PIL, `bilinear` for torch (chosen because bicubic divergence is nastiest to debug; bilinear's difference from PIL's BILINEAR is small). Cross-backend conversions (PIL ↔ Tensor) go through a normalized float32 `[0, 1]` intermediate. No auto-selection: callers pick the backend explicitly and own the consequences.
|
|
232
232
|
|
|
233
233
|
## 6. Quantization (optional)
|
|
234
234
|
|
|
@@ -331,7 +331,7 @@ Retorna `PatchPair(lr_patches, hr_patches, metas)` (frozen dataclass com `__slot
|
|
|
331
331
|
- `dilation` customizado (fixo em 1, porque o round-trip exige).
|
|
332
332
|
- Coerção implícita LR→HR de dtype/device (caller normaliza antes).
|
|
333
333
|
|
|
334
|
-
### 9.4 `resize(image, target_size, backend="pil", resample=None)`
|
|
334
|
+
### 9.4 `resize(image, target_size, backend="pil", resample=None, *, antialias=None)`
|
|
335
335
|
|
|
336
336
|
**Aceita:**
|
|
337
337
|
- `image` é `PIL.Image` em qualquer mode suportado pelo PIL.
|
|
@@ -339,6 +339,7 @@ Retorna `PatchPair(lr_patches, hr_patches, metas)` (frozen dataclass com `__slot
|
|
|
339
339
|
- `image` é `Tensor` em CUDA **apenas se** `backend == "torch"`.
|
|
340
340
|
- `target_size = (H, W)` tupla de 2 ints positivos.
|
|
341
341
|
- `resample=None` → default por backend (LANCZOS pra PIL, bilinear pra torch).
|
|
342
|
+
- `antialias`: `None` (default), `False` ou `True`, com o nome e o sentido do `resize` do torchvision v2 (ADR 0004). Com `True`, o backend torch filtra antes de reduzir em `bilinear` e `bicubic`, como o PIL sempre faz: na imagem de ruído dos testes, reduzida 4×, o resultado fica a menos de 1/255 do backend PIL, contra mais de 20/255 sem o filtro (`tests/test_resize.py`). Meia precisão é filtrada em `float32` e convertida de volta, como no torchvision, porque o torch não tem kernel com antialias para ela. `None` e `False` deixam a chamada ao `F.interpolate` exatamente como era, então a saída padrão não muda. Nos outros modos do torch e no backend PIL o flag não tem sentido e é ignorado, como no torchvision. O caminho filtrado usa o bicúbico do PIL (`a = -0,5`) onde o comum usa o do torch (`a = -0,75`), então também muda a ampliação bicúbica; serve para reduzir.
|
|
342
343
|
- Tensor inteiro com `backend == "torch"`: interpolação em float, cast de volta com **round + clamp** ao range do dtype (bicubic ultrapassa o range de entrada legitimamente, e um cast cru wrapava: `-9.0 → 247` em uint8; corrigido em 0.2.1). Vale para **todos os cinco modos** aceitos, não só para os dois que ultrapassam: o `F.interpolate` não implementa nenhum deles para inteiro, e até 0.5.3 a promoção era condicionada a `bilinear`/`bicubic`, então 13 das 25 combinações modo × dtype levantavam um erro cru do torch (`"adaptive_avg_pool2d" not implemented for 'Int'`) contra o que esta cláusula promete. A promoção é para o **float mais estreito que representa a faixa inteira do dtype exatamente**: `float32` para `uint8`, `int8` e `int16`, cujas faixas cabem nos 24 bits de mantissa, e `float64` para `int32`, que não cabe (`2**24 + 1` voltava `2**24`, em silêncio, desde sempre no caminho bilinear). `int64` é a exceção que nenhum float cobre: acima de `2**53` o valor não é exatamente representável em lugar nenhum e não sobrevive à volta.
|
|
343
344
|
- Tensor float com `backend == "pil"`: o hop uint8 pelo PIL **clampa a `[0, 1]`**; valores fora desse intervalo não sobrevivem nesse backend (comportamento documentado, não exceção).
|
|
344
345
|
- Tensor inteiro com `backend == "pil"`: o mesmo hop uint8 **clampa a `[0, 255]`** na ida e satura ao range do dtype na volta. Os dois backends recusam-se a dar a volta (`wrap`), que é a classe de defeito da 0.2.0, mas saturam em ranges diferentes de propósito: o torch preserva 300 e −9 num int32, o PIL não. Até 0.5.1 a ida fazia `clamp(0, 255)` no dtype de entrada, e num `int8` o próprio limite era irrepresentável, então o torch levantava `RuntimeError` cru antes de limitar coisa alguma.
|
|
@@ -348,6 +349,7 @@ Retorna `PatchPair(lr_patches, hr_patches, metas)` (frozen dataclass com `__slot
|
|
|
348
349
|
- `target_size` não é 2-tupla ou contém valor ≤ 0.
|
|
349
350
|
- `backend` não em `{"pil", "torch"}`.
|
|
350
351
|
- `resample` não suportado pelo backend escolhido (mensagem amigável, não trace interno).
|
|
352
|
+
- `antialias` que não seja `bool` nem `None`.
|
|
351
353
|
|
|
352
354
|
**Fora de escopo v0.1:**
|
|
353
355
|
- Auto-seleção de backend.
|
|
@@ -459,7 +461,7 @@ The blending counterpart to `reconstruct`. Same fold geometry, same rejections;
|
|
|
459
461
|
- `patches` is a 4-D `(L, C, ph, pw)` floating-point tensor (`float16`, `bfloat16`, `float32`, `float64`).
|
|
460
462
|
- `image_shape == (C, H, W)` consistent with the patch grid implied by `patches.shape[0]`, `stride`, and `(ph, pw)`.
|
|
461
463
|
- `stride` as `int` or `(int, int)` with `1 ≤ stride ≤ patch_size` on every axis.
|
|
462
|
-
- `weight` ∈ `{"uniform", "hann", "gaussian"}`. `"uniform"`
|
|
464
|
+
- `weight` ∈ `{"uniform", "hann", "gaussian"}`. `"uniform"` returns what `reconstruct` returns, bit for bit on an overlapping grid, and on a non-overlapping one up to the sign of a zero and the bits of a NaN (§2.5).
|
|
463
465
|
- `dtype` and `device` preserved on output.
|
|
464
466
|
|
|
465
467
|
**Sinaliza (não exceção):**
|
|
@@ -449,8 +449,23 @@ tensor([[ 2.5000, 2.5000, 4.5000, 4.5000],
|
|
|
449
449
|
|
|
450
450
|
The round trip does not return `tiny`: a resize discards what it averages,
|
|
451
451
|
which is why ADR 0003 counts it among the inexact operations. The averaging
|
|
452
|
-
holds at a factor of 2
|
|
453
|
-
than averages
|
|
452
|
+
holds at a factor of 2. At larger factors the torch backend samples rather
|
|
453
|
+
than averages unless you pass `antialias=True`, which filters first, as the
|
|
454
|
+
PIL backend always does. A strip with two bright pixels, reduced by 4:
|
|
455
|
+
|
|
456
|
+
```python
|
|
457
|
+
>>> spikes = torch.tensor([[[0., 0., 0., 8., 0., 0., 0., 8.]]])
|
|
458
|
+
>>> resize(spikes, target_size=(1, 2), backend="torch")[0]
|
|
459
|
+
tensor([[0., 0.]])
|
|
460
|
+
>>> resize(spikes, target_size=(1, 2), backend="torch", antialias=True)[0]
|
|
461
|
+
tensor([[1.4286, 2.2857]])
|
|
462
|
+
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
Unfiltered, each output interpolates the two inputs nearest to it, and both
|
|
466
|
+
pairs are zeros, so the bright pixels vanish. Filtered, every input under an
|
|
467
|
+
output counts, so they are averaged in. The default stays unfiltered, so an
|
|
468
|
+
existing call returns what it always did.
|
|
454
469
|
|
|
455
470
|
PIL in → PIL out. Tensor in → Tensor out. Cross-backend
|
|
456
471
|
(tensor + `backend="pil"`, or PIL + `backend="torch"`) is supported
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.6.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 6, 0)
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'g737570549'
|
|
@@ -127,6 +127,7 @@ def _resize_torch(
|
|
|
127
127
|
tensor: torch.Tensor,
|
|
128
128
|
target_size: tuple[int, int],
|
|
129
129
|
resample: str | None,
|
|
130
|
+
antialias: bool | None = None,
|
|
130
131
|
) -> torch.Tensor:
|
|
131
132
|
if resample is None:
|
|
132
133
|
mode = "bilinear"
|
|
@@ -151,6 +152,17 @@ def _resize_torch(
|
|
|
151
152
|
kwargs: dict[str, Any] = {"size": target_size, "mode": mode}
|
|
152
153
|
if mode in {"bilinear", "bicubic"}:
|
|
153
154
|
kwargs["align_corners"] = False
|
|
155
|
+
# Only True reaches F.interpolate, and only for the two modes it
|
|
156
|
+
# implements antialiasing for; elsewhere the flag means nothing and is
|
|
157
|
+
# ignored, as torchvision's resize ignores it. None and False leave the
|
|
158
|
+
# call exactly as it was, so the default output is unchanged.
|
|
159
|
+
if antialias:
|
|
160
|
+
kwargs["antialias"] = True
|
|
161
|
+
# torch implements the antialiased kernels for float32 and float64
|
|
162
|
+
# only, and raises on half precision; filter it in float32 and cast
|
|
163
|
+
# back, as torchvision's resize does.
|
|
164
|
+
if x.dtype in (torch.float16, torch.bfloat16):
|
|
165
|
+
x = x.float()
|
|
154
166
|
out = F.interpolate(x, **kwargs)
|
|
155
167
|
if not original_dtype.is_floating_point:
|
|
156
168
|
# Integer dtypes: bicubic/bilinear overshoot the input range, and a
|
|
@@ -166,6 +178,8 @@ def resize(
|
|
|
166
178
|
target_size: tuple[int, int],
|
|
167
179
|
backend: Literal["pil", "torch"] = "pil",
|
|
168
180
|
resample: str | None = None,
|
|
181
|
+
*,
|
|
182
|
+
antialias: bool | None = None,
|
|
169
183
|
) -> torch.Tensor | PILImage:
|
|
170
184
|
"""Resize a single image, preserving input type.
|
|
171
185
|
|
|
@@ -179,6 +193,19 @@ def resize(
|
|
|
179
193
|
bilinear for ``"torch"``. The accepted resample strings differ between
|
|
180
194
|
backends; an unsupported choice raises ``ValueError``.
|
|
181
195
|
|
|
196
|
+
``antialias=True`` makes the ``"torch"`` backend low-pass filter before
|
|
197
|
+
it downsamples, for ``bilinear`` and ``bicubic``, which is what PIL always
|
|
198
|
+
does; without it each output pixel interpolates only its nearest inputs
|
|
199
|
+
and the rest are skipped, so fine detail aliases. The filtered path is
|
|
200
|
+
torch's antialiased kernel, which also uses PIL's bicubic (``a = -0.5``)
|
|
201
|
+
where the plain path uses torch's (``a = -0.75``), so it changes bicubic
|
|
202
|
+
enlargement too; it is meant for reducing. Half precision is filtered in
|
|
203
|
+
``float32`` and cast back. The flag means nothing for the other torch
|
|
204
|
+
modes and for the ``"pil"`` backend, and is ignored there, as in
|
|
205
|
+
torchvision's resize, whose name and meaning it borrows. ``None``, the
|
|
206
|
+
default, and ``False`` keep the torch backend unfiltered, exactly as
|
|
207
|
+
before the flag existed (ADR 0004).
|
|
208
|
+
|
|
182
209
|
Dtype notes. Integer tensors on the ``"torch"`` backend are interpolated
|
|
183
210
|
in float and cast back with round + clamp to the dtype range, because
|
|
184
211
|
bicubic legitimately overshoots the input range and a bare cast wraps
|
|
@@ -195,9 +222,14 @@ def resize(
|
|
|
195
222
|
Rejects (per §9.4): non-2-tuple or non-positive ``target_size``;
|
|
196
223
|
``backend`` not in ``{"pil", "torch"}``; CUDA tensor with
|
|
197
224
|
``backend="pil"``; unsupported ``resample`` for the chosen backend;
|
|
198
|
-
PIL tensor conversion of an unsupported channel count
|
|
225
|
+
PIL tensor conversion of an unsupported channel count; ``antialias`` that
|
|
226
|
+
is neither a ``bool`` nor ``None``.
|
|
199
227
|
"""
|
|
200
228
|
target_size = _validate_target_size(target_size)
|
|
229
|
+
if antialias is not None and not isinstance(antialias, bool):
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"antialias must be bool or None, got {type(antialias).__name__} {antialias!r}"
|
|
232
|
+
)
|
|
201
233
|
if backend not in {"pil", "torch"}:
|
|
202
234
|
raise ValueError(f"backend must be 'pil' or 'torch', got {backend!r}")
|
|
203
235
|
|
|
@@ -225,7 +257,7 @@ def resize(
|
|
|
225
257
|
info = torch.iinfo(original_dtype)
|
|
226
258
|
tensor_out = (tensor_out * 255).round().clamp(info.min, info.max)
|
|
227
259
|
return tensor_out.to(original_dtype)
|
|
228
|
-
return _resize_torch(image, target_size, resample)
|
|
260
|
+
return _resize_torch(image, target_size, resample, antialias)
|
|
229
261
|
|
|
230
262
|
# PIL branch.
|
|
231
263
|
try:
|
|
@@ -240,5 +272,5 @@ def resize(
|
|
|
240
272
|
return _resize_pil(image, target_size, resample)
|
|
241
273
|
# PIL + backend == "torch"
|
|
242
274
|
tensor = _pil_to_tensor_f32(image)
|
|
243
|
-
resized = _resize_torch(tensor, target_size, resample)
|
|
275
|
+
resized = _resize_torch(tensor, target_size, resample, antialias)
|
|
244
276
|
return _tensor_to_pil_u8(resized)
|
|
@@ -8,8 +8,11 @@ denoised, super-resolved, or otherwise altered, where overlap seams are
|
|
|
8
8
|
visible if patches are averaged uniformly. Weighting by a window kernel
|
|
9
9
|
(Hann, Gaussian) emphasizes patch centers and reduces those seams.
|
|
10
10
|
|
|
11
|
-
With ``weight="uniform"``, ``stitch``
|
|
12
|
-
|
|
11
|
+
With ``weight="uniform"``, ``stitch`` returns what ``reconstruct`` returns:
|
|
12
|
+
on an overlapping grid it is the same computation, bit for bit. On a
|
|
13
|
+
non-overlapping one ``reconstruct`` only copies, while ``stitch`` returns what
|
|
14
|
+
the fold it stands in for would: ``+0.0`` for a ``-0.0``, and a NaN that stays
|
|
15
|
+
a NaN but may change its bits.
|
|
13
16
|
|
|
14
17
|
Contract: docs/THEORY.md §2.5 and §9.9.
|
|
15
18
|
"""
|
|
@@ -22,6 +25,7 @@ import torch.nn.functional as F # noqa: N812 (torch convention)
|
|
|
22
25
|
|
|
23
26
|
from patchcraft._accel import fold_weighted
|
|
24
27
|
from patchcraft._foldgeom import check_fold_geometry
|
|
28
|
+
from patchcraft.reconstruct import reconstruct
|
|
25
29
|
|
|
26
30
|
__all__ = ["WeightKind", "stitch"]
|
|
27
31
|
|
|
@@ -153,7 +157,10 @@ def stitch(
|
|
|
153
157
|
built from two 1-D window folds (the kernel is separable, so the 2-D
|
|
154
158
|
denominator is their outer product). ``numerator / denominator`` gives
|
|
155
159
|
the output; geometry validation guarantees no uncovered pixels and all
|
|
156
|
-
three windows are strictly positive.
|
|
160
|
+
three windows are strictly positive. Under ``"uniform"`` every weight is
|
|
161
|
+
1, so an overlapping grid goes to :func:`patchcraft.reconstruct`, which is
|
|
162
|
+
the same arithmetic without the multiply and the window folds, and a
|
|
163
|
+
non-overlapping grid is only rearranged.
|
|
157
164
|
|
|
158
165
|
Rejects (per §9.9): ``dilation != 1``; ``stride > patch_size`` in any
|
|
159
166
|
axis; grids that do not cover the image exactly (same coverage guard as
|
|
@@ -214,6 +221,16 @@ def stitch(
|
|
|
214
221
|
out = grid.permute(2, 0, 3, 1, 4).reshape(c, h, w)
|
|
215
222
|
return (out + 0.0).to(patches.dtype)
|
|
216
223
|
|
|
224
|
+
if weight == "uniform":
|
|
225
|
+
# Overlapping grid under the uniform window. Every weight is 1, so the
|
|
226
|
+
# weighted fold is the plain fold, in the same order, and the
|
|
227
|
+
# separable denominator is the count map: exactly the arithmetic
|
|
228
|
+
# reconstruct performs, without the multiply by ones and the two
|
|
229
|
+
# window folds. Bit-identical to the general path below, values,
|
|
230
|
+
# signed zeros, NaN payloads and gradients included; the speedup is
|
|
231
|
+
# measured in the 0.6.0 entry of CHANGELOG.md.
|
|
232
|
+
return reconstruct(patches, image_shape, stride)
|
|
233
|
+
|
|
217
234
|
wh = _window_1d(weight, ph, accum_dtype, patches.device)
|
|
218
235
|
ww = _window_1d(weight, pw, accum_dtype, patches.device)
|
|
219
236
|
kernel = wh.unsqueeze(1) * ww.unsqueeze(0)
|
|
@@ -7,6 +7,7 @@ import torch
|
|
|
7
7
|
from PIL import Image
|
|
8
8
|
|
|
9
9
|
from patchcraft import resize
|
|
10
|
+
from tests._rng import bit_equal
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
def _rgb_pil(h: int, w: int) -> Image.Image:
|
|
@@ -243,6 +244,134 @@ class TestBackendDivergence:
|
|
|
243
244
|
assert out_torch.shape == out_pil.shape == (1, 8, 8)
|
|
244
245
|
|
|
245
246
|
|
|
247
|
+
class TestAntialias:
|
|
248
|
+
"""``antialias`` (ADR 0004): torchvision v2's flag, acting on the torch backend.
|
|
249
|
+
|
|
250
|
+
The default leaves the call as it always was; ``True`` filters before a
|
|
251
|
+
bilinear or bicubic downsample, which is what PIL always does."""
|
|
252
|
+
|
|
253
|
+
@staticmethod
|
|
254
|
+
def _noise() -> torch.Tensor:
|
|
255
|
+
return torch.rand(3, 256, 256, generator=torch.Generator().manual_seed(0))
|
|
256
|
+
|
|
257
|
+
def test_default_and_false_leave_the_call_unchanged(self) -> None:
|
|
258
|
+
x = self._noise()
|
|
259
|
+
default = resize(x, (64, 64), backend="torch")
|
|
260
|
+
plain = torch.nn.functional.interpolate(
|
|
261
|
+
x.unsqueeze(0), size=(64, 64), mode="bilinear", align_corners=False
|
|
262
|
+
)[0]
|
|
263
|
+
assert torch.equal(default, plain)
|
|
264
|
+
assert torch.equal(resize(x, (64, 64), backend="torch", antialias=False), default)
|
|
265
|
+
|
|
266
|
+
@pytest.mark.parametrize(
|
|
267
|
+
"dtype", [torch.float32, torch.float64, torch.float16, torch.bfloat16]
|
|
268
|
+
)
|
|
269
|
+
@pytest.mark.parametrize("mode", ["bilinear", "bicubic"])
|
|
270
|
+
def test_true_equals_torchvision_resize_for_float_tensors(
|
|
271
|
+
self, mode: str, dtype: torch.dtype
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Float tensors only: for integers torchvision rounds differently (uint8
|
|
274
|
+
has a native fixed-point kernel), and resize rounds and clamps."""
|
|
275
|
+
tv = pytest.importorskip("torchvision.transforms.v2.functional")
|
|
276
|
+
x = self._noise().to(dtype)
|
|
277
|
+
ours = resize(x, (64, 64), backend="torch", resample=mode, antialias=True)
|
|
278
|
+
interpolation = getattr(tv.InterpolationMode, mode.upper())
|
|
279
|
+
theirs = tv.resize(x, [64, 64], interpolation=interpolation, antialias=True)
|
|
280
|
+
assert ours.dtype == dtype
|
|
281
|
+
assert bit_equal(ours, theirs)
|
|
282
|
+
|
|
283
|
+
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
|
284
|
+
@pytest.mark.parametrize("mode", ["bilinear", "bicubic"])
|
|
285
|
+
def test_half_precision_is_filtered_in_float32(self, mode: str, dtype: torch.dtype) -> None:
|
|
286
|
+
"""torch has no antialiased half-precision kernel and raised on it."""
|
|
287
|
+
x = self._noise().to(dtype)
|
|
288
|
+
got = resize(x, (64, 64), backend="torch", resample=mode, antialias=True)
|
|
289
|
+
via = resize(x.float(), (64, 64), backend="torch", resample=mode, antialias=True)
|
|
290
|
+
assert bit_equal(got, via.to(dtype))
|
|
291
|
+
assert bit_equal(resize(x, (256, 256), backend="torch", antialias=True), x)
|
|
292
|
+
|
|
293
|
+
def test_a_pil_image_on_the_torch_backend_takes_the_flag(self) -> None:
|
|
294
|
+
from patchcraft.resize import _pil_to_tensor_f32, _tensor_to_pil_u8
|
|
295
|
+
|
|
296
|
+
stripes = Image.fromarray((np.indices((64, 64)).sum(0) % 2 * 255).astype(np.uint8))
|
|
297
|
+
filtered = resize(stripes, (16, 16), backend="torch", antialias=True)
|
|
298
|
+
plain = resize(stripes, (16, 16), backend="torch")
|
|
299
|
+
assert not np.array_equal(np.asarray(filtered), np.asarray(plain))
|
|
300
|
+
via = _tensor_to_pil_u8(
|
|
301
|
+
resize(_pil_to_tensor_f32(stripes), (16, 16), backend="torch", antialias=True)
|
|
302
|
+
)
|
|
303
|
+
assert np.array_equal(np.asarray(filtered), np.asarray(via))
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize(("flag", "a"), [(False, -0.75), (True, -0.5)])
|
|
306
|
+
def test_the_filtered_bicubic_is_pils_kernel(self, flag: bool, a: float) -> None:
|
|
307
|
+
"""Enlarging an impulse traces the kernel: torch's Keys a = -0.75
|
|
308
|
+
without the flag, PIL's a = -0.5 with it, which is why the flag also
|
|
309
|
+
changes bicubic enlargement."""
|
|
310
|
+
|
|
311
|
+
def keys(t: float) -> float:
|
|
312
|
+
t = abs(t)
|
|
313
|
+
if t <= 1:
|
|
314
|
+
return (a + 2) * t**3 - (a + 3) * t**2 + 1
|
|
315
|
+
return a * t**3 - 5 * a * t**2 + 8 * a * t - 4 * a if t < 2 else 0.0
|
|
316
|
+
|
|
317
|
+
impulse = torch.zeros(1, 1, 16, dtype=torch.float64)
|
|
318
|
+
impulse[..., 8] = 1.0
|
|
319
|
+
up = resize(impulse, (1, 64), backend="torch", resample="bicubic", antialias=flag)[0, 0]
|
|
320
|
+
offsets = [(j + 0.5) / 4 - 0.5 - 8 for j in range(64)]
|
|
321
|
+
kernel = torch.tensor([keys(t) for t in offsets], dtype=torch.float64)
|
|
322
|
+
torch.testing.assert_close(up, kernel, rtol=0, atol=1e-12)
|
|
323
|
+
|
|
324
|
+
@pytest.mark.parametrize("mode", ["bilinear", "bicubic"])
|
|
325
|
+
def test_true_agrees_with_pil_when_downsampling(self, mode: str) -> None:
|
|
326
|
+
"""Within the PIL backend's own uint8 rounding; the plain path does not come close."""
|
|
327
|
+
x = (self._noise()[:, :64, :64] * 255).round() / 255
|
|
328
|
+
pil = resize(x, (16, 16), backend="pil", resample=mode)
|
|
329
|
+
filtered = resize(x, (16, 16), backend="torch", resample=mode, antialias=True)
|
|
330
|
+
plain = resize(x, (16, 16), backend="torch", resample=mode)
|
|
331
|
+
assert (filtered - pil).abs().max() <= 1.0 / 255 + 1e-6
|
|
332
|
+
assert (plain - pil).abs().max() > 20.0 / 255
|
|
333
|
+
|
|
334
|
+
def test_noise_is_averaged_rather_than_sampled(self) -> None:
|
|
335
|
+
x = self._noise()
|
|
336
|
+
plain = resize(x, (64, 64), backend="torch")
|
|
337
|
+
filtered = resize(x, (64, 64), backend="torch", antialias=True)
|
|
338
|
+
assert filtered.std() < 0.4 * plain.std()
|
|
339
|
+
|
|
340
|
+
@pytest.mark.parametrize("mode", ["bilinear", "bicubic"])
|
|
341
|
+
def test_resizing_to_the_same_size_returns_the_input(self, mode: str) -> None:
|
|
342
|
+
"""The round trip a resize has: to the size it already is, bit for bit."""
|
|
343
|
+
x = self._noise()
|
|
344
|
+
same = resize(x, (256, 256), backend="torch", resample=mode, antialias=True)
|
|
345
|
+
assert torch.equal(same, x)
|
|
346
|
+
|
|
347
|
+
@pytest.mark.parametrize("mode", ["nearest", "area", "nearest-exact"])
|
|
348
|
+
def test_ignored_by_the_modes_without_antialiasing(self, mode: str) -> None:
|
|
349
|
+
x = self._noise()
|
|
350
|
+
with_flag = resize(x, (64, 64), backend="torch", resample=mode, antialias=True)
|
|
351
|
+
assert torch.equal(with_flag, resize(x, (64, 64), backend="torch", resample=mode))
|
|
352
|
+
|
|
353
|
+
@pytest.mark.parametrize("flag", [True, False])
|
|
354
|
+
def test_ignored_by_the_pil_backend(self, flag: bool) -> None:
|
|
355
|
+
x = self._noise()
|
|
356
|
+
with_flag = resize(x, (64, 64), backend="pil", antialias=flag)
|
|
357
|
+
assert torch.equal(with_flag, resize(x, (64, 64), backend="pil"))
|
|
358
|
+
|
|
359
|
+
def test_integer_tensors_round_and_clamp_the_filtered_result(self) -> None:
|
|
360
|
+
u8 = (self._noise() * 255).round().to(torch.uint8)
|
|
361
|
+
got = resize(u8, (64, 64), backend="torch", antialias=True)
|
|
362
|
+
ref = torch.nn.functional.interpolate(
|
|
363
|
+
u8.unsqueeze(0).float(), size=(64, 64), mode="bilinear",
|
|
364
|
+
align_corners=False, antialias=True,
|
|
365
|
+
)[0].round().clamp(0, 255).to(torch.uint8)
|
|
366
|
+
assert got.dtype == torch.uint8
|
|
367
|
+
assert torch.equal(got, ref)
|
|
368
|
+
|
|
369
|
+
@pytest.mark.parametrize("bad", [1, 0, "yes"])
|
|
370
|
+
def test_rejects_anything_but_bool_or_none(self, bad: object) -> None:
|
|
371
|
+
with pytest.raises(ValueError, match="antialias must be bool or None"):
|
|
372
|
+
resize(self._noise(), (64, 64), backend="torch", antialias=bad) # type: ignore[arg-type]
|
|
373
|
+
|
|
374
|
+
|
|
246
375
|
# ------------------------------------------------------------------ Rejeita ---
|
|
247
376
|
|
|
248
377
|
class TestRejects:
|
|
@@ -47,19 +47,39 @@ class TestUniformEqualsReconstruct:
|
|
|
47
47
|
out = stitch(patches, image_shape=img.shape, stride=4, weight="uniform")
|
|
48
48
|
assert bit_equal(out, img)
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
50
|
+
@pytest.mark.parametrize("accel", ["1", "0"], ids=["accel", "pure"])
|
|
51
|
+
@pytest.mark.parametrize(
|
|
52
|
+
"dtype", [torch.float32, torch.float64, torch.float16, torch.bfloat16]
|
|
53
|
+
)
|
|
54
|
+
@pytest.mark.parametrize(
|
|
55
|
+
("h", "w", "p", "s"), [(16, 16, 4, 2), (12, 10, (4, 3), (2, 1)), (9, 7, 3, (1, 2))]
|
|
56
|
+
)
|
|
57
|
+
def test_overlap_is_reconstruct_bit_for_bit(
|
|
58
|
+
self,
|
|
59
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
60
|
+
accel: str,
|
|
61
|
+
dtype: torch.dtype,
|
|
62
|
+
h: int,
|
|
63
|
+
w: int,
|
|
64
|
+
p: int | tuple[int, int],
|
|
65
|
+
s: int | tuple[int, int],
|
|
66
|
+
) -> None:
|
|
67
|
+
"""On an overlapping grid the uniform window delegates to reconstruct,
|
|
68
|
+
so the two agree bit for bit, counts of 3 included, on patches that
|
|
69
|
+
disagree and on signed zeros, infinities and NaN payloads."""
|
|
70
|
+
monkeypatch.setenv("PATCHCRAFT_ACCEL", accel)
|
|
71
|
+
n, _, ph, pw = extract(torch.zeros(1, h, w), p, s).shape
|
|
72
|
+
g = torch.Generator().manual_seed(503)
|
|
73
|
+
patches = (torch.rand(n, 2, ph, pw, generator=g) * 2 - 1).to(dtype)
|
|
74
|
+
flat = patches.view(-1)
|
|
75
|
+
flat[:5] = torch.tensor([0.0, -0.0, float("inf"), float("-inf"), float("nan")])
|
|
76
|
+
out_stitch = stitch(patches, image_shape=(2, h, w), stride=s)
|
|
77
|
+
out_recon = reconstruct(patches, image_shape=(2, h, w), stride=s)
|
|
78
|
+
assert bit_equal(out_stitch, out_recon)
|
|
58
79
|
|
|
59
80
|
def test_overlap_recovers_image(self) -> None:
|
|
60
81
|
"""counts are 1, 2, 4 (powers of two): uniform stitch is bit-exact
|
|
61
|
-
here,
|
|
62
|
-
reproduces the integer count map exactly."""
|
|
82
|
+
here, since it is reconstruct's arithmetic on an overlapping grid."""
|
|
63
83
|
img = rand_image(1, 16, 16, torch.float64, seed=504)
|
|
64
84
|
patches = extract(img, patch_size=4, stride=2)
|
|
65
85
|
out = stitch(patches, image_shape=img.shape, stride=2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|