patchcraft 0.5.1__tar.gz → 0.5.2__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.
Files changed (79) hide show
  1. {patchcraft-0.5.1 → patchcraft-0.5.2}/CHANGELOG.md +81 -1
  2. {patchcraft-0.5.1 → patchcraft-0.5.2}/CONTRIBUTING.md +59 -3
  3. {patchcraft-0.5.1 → patchcraft-0.5.2}/MANIFEST.in +3 -1
  4. {patchcraft-0.5.1 → patchcraft-0.5.2}/PKG-INFO +1 -1
  5. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/ADR/0003-reversibility-classes.md +1 -0
  6. patchcraft-0.5.2/docs/ADR/0004-precision-and-effort-parameters.md +227 -0
  7. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/GUIDE.md +2 -2
  8. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/THEORY.md +5 -4
  9. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/_version.py +3 -3
  10. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/reconstruct.py +8 -1
  11. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/resize.py +12 -3
  12. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/stitch.py +10 -4
  13. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/PKG-INFO +1 -1
  14. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/SOURCES.txt +4 -6
  15. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/scm_file_list.json +12 -6
  16. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/scm_version.json +2 -2
  17. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_exactness.py +1 -1
  18. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_reconstruct.py +21 -0
  19. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_resize.py +35 -0
  20. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_stitch.py +35 -0
  21. patchcraft-0.5.1/docs/superpowers/plans/2026-09-01-fase1-bugs-performance.md +0 -1179
  22. patchcraft-0.5.1/docs/superpowers/plans/2026-09-01-fase2-acel.md +0 -1643
  23. patchcraft-0.5.1/docs/superpowers/plans/2026-09-01-fase3-g1-predicado.md +0 -1705
  24. {patchcraft-0.5.1 → patchcraft-0.5.2}/.gitignore +0 -0
  25. {patchcraft-0.5.1 → patchcraft-0.5.2}/.python-version +0 -0
  26. {patchcraft-0.5.1 → patchcraft-0.5.2}/LICENSE +0 -0
  27. {patchcraft-0.5.1 → patchcraft-0.5.2}/README.md +0 -0
  28. {patchcraft-0.5.1 → patchcraft-0.5.2}/README.pt-BR.md +0 -0
  29. {patchcraft-0.5.1 → patchcraft-0.5.2}/README.pypi.md +0 -0
  30. {patchcraft-0.5.1 → patchcraft-0.5.2}/accel/Cargo.lock +0 -0
  31. {patchcraft-0.5.1 → patchcraft-0.5.2}/accel/Cargo.toml +0 -0
  32. {patchcraft-0.5.1 → patchcraft-0.5.2}/accel/README.md +0 -0
  33. {patchcraft-0.5.1 → patchcraft-0.5.2}/accel/src/kernel.rs +0 -0
  34. {patchcraft-0.5.1 → patchcraft-0.5.2}/accel/src/lib.rs +0 -0
  35. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/ADR/0001-patch-extraction-api.md +0 -0
  36. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/ADR/0002-patchify-transform.md +0 -0
  37. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/AUXILIARY.md +0 -0
  38. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/FOCO-1.0.md +0 -0
  39. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/PERFORMANCE.md +0 -0
  40. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/ROADMAP.md +0 -0
  41. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/SCOPE.md +0 -0
  42. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/STUDIES/2026-08-04-patch-techniques.md +0 -0
  43. {patchcraft-0.5.1 → patchcraft-0.5.2}/docs/USAGE.md +0 -0
  44. /patchcraft-0.5.1/docs/superpowers/specs/2026-09-01-fase1-bugs-performance-design.md → /patchcraft-0.5.2/docs/design/2026-09-01-fase1-bugs-performance.md +0 -0
  45. /patchcraft-0.5.1/docs/superpowers/specs/2026-09-01-fase2-acel-design.md → /patchcraft-0.5.2/docs/design/2026-09-01-fase2-acelerador.md +0 -0
  46. /patchcraft-0.5.1/docs/superpowers/specs/2026-09-01-fase3-g1-predicado-design.md → /patchcraft-0.5.2/docs/design/2026-09-01-fase3-predicado.md +0 -0
  47. {patchcraft-0.5.1 → patchcraft-0.5.2}/pyproject.toml +0 -0
  48. {patchcraft-0.5.1 → patchcraft-0.5.2}/setup.cfg +0 -0
  49. {patchcraft-0.5.1 → patchcraft-0.5.2}/setup.py +0 -0
  50. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/__init__.py +0 -0
  51. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/_accel.py +0 -0
  52. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/_foldgeom.py +0 -0
  53. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/cache.py +0 -0
  54. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/extract.py +0 -0
  55. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/geometry.py +0 -0
  56. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/metrics.py +0 -0
  57. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/pair.py +0 -0
  58. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft/py.typed +0 -0
  59. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/dependency_links.txt +0 -0
  60. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/requires.txt +0 -0
  61. {patchcraft-0.5.1 → patchcraft-0.5.2}/src/patchcraft.egg-info/top_level.txt +0 -0
  62. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/__init__.py +0 -0
  63. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/_datasets.py +0 -0
  64. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/_rng.py +0 -0
  65. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/conftest.py +0 -0
  66. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_accel.py +0 -0
  67. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_cache.py +0 -0
  68. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_datasets_helper.py +0 -0
  69. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_extract.py +0 -0
  70. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_geometry.py +0 -0
  71. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_import.py +0 -0
  72. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_metrics.py +0 -0
  73. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_pair.py +0 -0
  74. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_public_api.py +0 -0
  75. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_reference.py +0 -0
  76. {patchcraft-0.5.1 → patchcraft-0.5.2}/tests/test_rng.py +0 -0
  77. {patchcraft-0.5.1 → patchcraft-0.5.2}/tools/benchmark.py +0 -0
  78. {patchcraft-0.5.1 → patchcraft-0.5.2}/tools/check_dist.py +0 -0
  79. {patchcraft-0.5.1 → patchcraft-0.5.2}/uv.lock +0 -0
@@ -6,6 +6,85 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.2] - 2026-09-03
10
+
11
+ ### Fixed
12
+
13
+ - **`stitch(weight="hann")` corrupted the corner band of every patch of 99 or
14
+ more.** The denominator was floored by an absolute `clamp(min=1e-6)` whose
15
+ comment called it a defensive no-op. It was not: the 2-D hann corner weight
16
+ is `(pi/(n+1))**4`, which crosses below `1e-6` at patch 99, so the corner
17
+ band was divided by the floor rather than by the real weight. Measured at
18
+ 640x640, patch 256, stride 128: maximum error 0.94 on data in [0, 1] and 960
19
+ wrong pixels, identical in float64 because the constant and not the precision
20
+ was the cause. The floor is now `finfo(dtype).tiny`, the model already used
21
+ in `metrics.py`, which never reaches a legitimate weight: the smallest 2-D
22
+ hann corner even at patch 1024 is 8.8e-11. THEORY 9.9 claimed no covered
23
+ pixel is zeroed by the window, which was true of the window and false of the
24
+ result.
25
+ - **`reconstruct` returned a view aliasing the caller's patches on a
26
+ single-patch grid.** Writing to the patches afterwards changed an image the
27
+ function had already returned, measured as 0.744 becoming 99.0. `extract`
28
+ guards the mirror image of this case and `reconstruct` did not.
29
+ - **`resize(..., backend="pil")` raised a raw `RuntimeError` on `int8`.** The
30
+ inbound `clamp(0, 255)` was applied in the input dtype, where the bound 255
31
+ is unrepresentable, so torch raised `value cannot be converted to type
32
+ int8_t without overflow` before clamping anything. Every integer dtype now
33
+ works on both backends, and THEORY 9.4 gained the row for integer tensors on
34
+ the PIL backend, which it had never specified.
35
+
36
+
37
+ ### Removed
38
+
39
+ - The agentic workflow's leftovers. `.superpowers/` at the repository root
40
+ held 62 files of review diffs and task-progress notes, hidden from
41
+ `git status` by a nested `.gitignore` containing `*`, so it sat in the tree
42
+ unseen and reached the 0.5.0 sdist. The three implementation plans under
43
+ `docs/superpowers/plans/` were 4527 lines of checkbox task lists addressed
44
+ "For agentic workers", and they shipped to PyPI inside every sdist.
45
+
46
+ ### Added
47
+
48
+ - `docs/ADR/0004-precision-and-effort-parameters.md`, in `Proposed` status: names the
49
+ precision policy the library already applies in five places, and designs an `effort=`
50
+ preset under the constraint that it may change speed and memory but never a returned
51
+ value. Nothing is implemented; the ADR records the design, the effort per step and the
52
+ order. It amends ADR 0003 on one measured point, that the exactness predicate is a
53
+ property of the geometry and the accumulator rather than of the geometry alone.
54
+ - `outreach/`, material for presenting the project publicly, in the shape the
55
+ sibling projects use: a dated news source at the root and one subfolder per
56
+ channel, with the rule that no channel text changes before the source does.
57
+ It publishes no new measurement, and it is pruned from the sdist.
58
+
59
+ ### Changed
60
+
61
+ - The three design specs that were under `docs/superpowers/specs/` moved to
62
+ `docs/design/`. They are the record of what was measured before each phase
63
+ was built, including the spike table that rejected `conv_transpose2d` and
64
+ `index_add_` before the Rust kernel was written, and Amendment A, which
65
+ `tests/test_exactness.py` cites for the per-pixel error bound.
66
+ - The sdist is 264 KiB where it was 316 KiB.
67
+ - Nothing ephemeral is kept in the project folder any more. `accel/target`
68
+ had reached 269 MiB of Rust build output, and `dist/`, `.pytest_cache` and
69
+ `src/patchcraft.egg-info` were accumulating alongside it. The caches are
70
+ now redirected by user-level environment variables, joining the ones this
71
+ machine already had for uv, ruff, mypy, pip and `__pycache__`, and
72
+ `CONTRIBUTING.md` records the convention and why it is not committed here.
73
+ - `.vscode/settings.json` stays tracked and is pruned from the sdist instead.
74
+ - **The versioning rule was self-contradictory and is corrected.** One row said
75
+ an output value change is a `0.Y.0`, the next said a fix is a `0.y.Z`, and a
76
+ bug fix satisfies both, so the table made every fix a minor and left patch
77
+ releases unable to fix anything. It also contradicted the project's own
78
+ history: `0.2.1` changed `stitch(weight="hann")` output for every geometry
79
+ and shipped as a patch. The rule now turns on whether the contract moved or
80
+ the implementation stopped violating it, which sorts `0.2.1`, `0.3.0`,
81
+ `0.5.0` and `0.5.2` correctly. `CONTRIBUTING.md` carries the rule with those
82
+ four worked, and GUIDE section 8 item 6 no longer cites a patch release as
83
+ an example of what only a minor may do.
84
+ Its `${workspaceFolder}/.venv` path is not broken: `.venv` is a directory
85
+ junction to the real environment outside the tree, so the setting resolves
86
+ and is portable to any checkout following the same convention.
87
+
9
88
  ## [0.5.1] - 2026-09-02
10
89
 
11
90
  > Numbered `0.6.0` while it was being built. Corrected before release: the
@@ -587,7 +666,8 @@ First public release. Public API stable; signatures will only change in 1.x.
587
666
  - [`README.md`](README.md) covers installation, the car-vs-track metaphor,
588
667
  validation lab.
589
668
 
590
- [Unreleased]: https://github.com/LeoPR/PatchCraft/compare/v0.5.1...HEAD
669
+ [Unreleased]: https://github.com/LeoPR/PatchCraft/compare/v0.5.2...HEAD
670
+ [0.5.2]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.2
591
671
  [0.5.1]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.1
592
672
  [0.5.0]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.5.0
593
673
 
@@ -68,6 +68,36 @@ CARGO_TARGET_DIR=/tmp/pc-target uv sync --extra cache --extra dev
68
68
 
69
69
  ---
70
70
 
71
+ ## Nothing ephemeral lives in the project folder
72
+
73
+ Caches, build output and virtual environments are kept off the tree, because
74
+ they are large, regenerable and machine-specific. They are redirected by
75
+ user-level environment variables rather than by anything committed here: a
76
+ machine path in `pyproject.toml` would be wrong for every other machine and
77
+ for CI, which is why one was removed in 0.3.0.
78
+
79
+ | Variable | Sends |
80
+ |---|---|
81
+ | `CARGO_TARGET_DIR` | the Rust build output, which reached 269 MiB inside `accel/target` before it was redirected |
82
+ | `PYTEST_ADDOPTS=-o cache_dir=...` | `.pytest_cache` |
83
+ | `RUFF_CACHE_DIR`, `MYPY_CACHE_DIR` | the linter caches |
84
+ | `UV_CACHE_DIR`, `PIP_CACHE_DIR` | downloaded wheels |
85
+ | `PYTHONPYCACHEPREFIX` | `__pycache__` |
86
+
87
+ The virtual environment lives outside the tree too; see
88
+ [`docs/AUXILIARY.md`](docs/AUXILIARY.md).
89
+
90
+ Two directories still appear during a build and are gitignored: `build/` and
91
+ `src/patchcraft.egg-info/`, both setuptools working state. They exist only
92
+ while you build, and redirecting them would need a machine path in a tracked
93
+ file, which is the thing this section is avoiding.
94
+
95
+ Three files are written into `src/patchcraft/` by an editable install and
96
+ are gitignored: the compiled `_accel_native`, the generated `_version.py`, and
97
+ `__pycache__`. Those have to be there for the package to import.
98
+
99
+ ---
100
+
71
101
  ## Layout
72
102
 
73
103
  ```
@@ -137,7 +167,11 @@ PatchCraft/
137
167
  │ ├── FOCO-1.0.md what 1.0 freezes and the blockers in the way
138
168
  │ ├── ADR/ one file per decision that shaped the API
139
169
  │ ├── STUDIES/ background reading behind those decisions
140
- │ └── superpowers/ the design specs and implementation plans per work phase
170
+ │ └── design/ one spec per work phase: the alternatives measured and the decision
171
+ ├── outreach/ material for presenting the project publicly, not shipped
172
+ │ ├── README.md how it is organized and what to check before publishing
173
+ │ ├── 2026-09-03-lancamento.md the dated news source every channel text derives from
174
+ │ └── linkedin/ post (short) and artigo (long), each in PT and EN
141
175
  └── archive/ reference-only; gitignored (pruned 2026-05-17, only HISTORY.md kept)
142
176
  ```
143
177
 
@@ -186,13 +220,35 @@ promise that nothing does.
186
220
 
187
221
  That fixes what each digit is for here:
188
222
 
223
+ The question that decides it is not "did a returned value change". A bug fix
224
+ changes returned values by definition, and if that forced a `0.Y.0` then a
225
+ patch release could never fix anything. The question is **whether the contract
226
+ moved, or whether the implementation stopped violating it**:
227
+
189
228
  | Change | Bump | Why |
190
229
  |---|---|---|
191
- | An output value, a signature, a name or documented behaviour changes | `0.Y.0` | Resolvers stop at the y boundary, so this is where a break belongs |
192
- | New functionality that breaks nothing, or a fix | `0.y.Z` | Anyone pinned to the y-series should get it |
230
+ | The documented contract moves: a signature, a name, or behaviour the documentation described as correct | `0.Y.0` | Code that was right against the old documentation can now be wrong |
231
+ | The implementation stops violating the contract, however visibly | `0.y.Z` | The documentation always promised this; only the code was wrong |
232
+ | New functionality that breaks nothing | `0.y.Z` | Anyone pinned to the y-series should get it |
193
233
  | Documentation, tests, CI, packaging or tooling only | `0.y.Z` | Still a release if it is published, still no break |
194
234
  | Nothing published | nothing | Accumulate under `[Unreleased]` |
195
235
 
236
+ The project's own history sorts cleanly under that rule, and does not sort
237
+ under any rule keyed on whether values changed:
238
+
239
+ - `0.2.1` rewrote the hann window, changing `stitch(weight="hann")` output for
240
+ **every** geometry. THEORY §9.9 had promised no covered pixel is zeroed by
241
+ the window; the old window zeroed 108 of 144 pixels in one measured case. A
242
+ violation, so a **z**, and it shipped as one.
243
+ - `0.5.2` floored the `stitch` denominator by the dtype's tiny instead of an
244
+ absolute `1e-6`, changing hann output at patch 99 and above. The same
245
+ promise, the same kind of violation, so also a **z**.
246
+ - `0.3.0` moved `stitch` output by ULPs through a different summation order.
247
+ Nothing was wrong before, so behaviour the documentation endorsed changed.
248
+ A **y**.
249
+ - `0.5.0` made `tilings` return 73 specs where it returned 100. The
250
+ enumeration's output set is the contract. A **y**.
251
+
196
252
  After 1.0 the ordinary reading applies: breaking is major, additive is minor,
197
253
  fixed is patch. What 1.0 freezes is written in
198
254
  [`docs/FOCO-1.0.md`](docs/FOCO-1.0.md).
@@ -32,6 +32,8 @@ recursive-include tools *.py
32
32
 
33
33
  prune lab
34
34
  prune archive
35
- prune .superpowers
36
35
  prune .github
37
36
  prune .vscode
37
+ # Presentation material, not source. It is tracked, so the file finder offers
38
+ # it and only this line keeps it out.
39
+ prune outreach
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchcraft
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Image patch extraction, reconstruction, pairing and seam-aware stitching for super-resolution and dataset pipelines.
5
5
  Author: Leonardo Marques de Souza
6
6
  License-Expression: MIT
@@ -3,6 +3,7 @@
3
3
  - **Status:** Proposed
4
4
  - **Date:** 2026-08-04
5
5
  - **Deciders:** Leonardo Marques de Souza
6
+ - **Amended by:** [ADR 0004](0004-precision-and-effort-parameters.md), which measures the exactness predicate below to be a property of the geometry **and the accumulator**, not of the geometry alone: with a `float64` accumulator, 76 of 76 sampled legal geometries are bit-exact where a `float32` accumulator gives 45, and those 45 are exactly the power-of-two ones.
6
7
  - **Relates to:** [ADR 0001](0001-patch-extraction-api.md), [`THEORY.md`](../THEORY.md) §0, §9, [`STUDIES/2026-08-04-patch-techniques.md`](../STUDIES/2026-08-04-patch-techniques.md) §4, §5
7
8
 
8
9
  ## Context
@@ -0,0 +1,227 @@
1
+ # ADR 0004: naming the precision policy, and an effort preset that may not change values
2
+
3
+ - **Status:** Proposed
4
+ - **Date:** 2026-09-03
5
+ - **Deciders:** Leonardo Marques de Souza
6
+ - **Relates to:** [ADR 0003](0003-reversibility-classes.md) (which this amends on one point), [`THEORY.md`](../THEORY.md) §9.1, §9.2, §9.9, [`FOCO-1.0.md`](../FOCO-1.0.md) §1, [`PERFORMANCE.md`](../PERFORMANCE.md)
7
+
8
+ ## Context
9
+
10
+ ### The library already has a precision policy. It has no name.
11
+
12
+ Five places decide precision on the caller's behalf, none of them visible from a call site:
13
+
14
+ | Site | The choice made for you |
15
+ |---|---|
16
+ | `reconstruct.py:55`, `stitch.py:189` | `float16` and `bfloat16` accumulate in `float32`, rounding once on return |
17
+ | `stitch.py:229` | the denominator is floored by `clamp(min=1e-6)`, an absolute constant |
18
+ | `metrics.py:67`, `:123` | accumulation promotes to `float64` whatever the input was |
19
+ | `metrics.py:164` | `clamp_min(finfo(float64).tiny)` before the PSNR logarithm |
20
+ | `resize.py:47`, `:148` | every cross-backend conversion passes through normalised `float32` in [0, 1] |
21
+
22
+ So the question is not whether to introduce a precision concept. The concept is already
23
+ here, applied by default and unnamed. The question is whether to give it a name.
24
+
25
+ ### What forced the question
26
+
27
+ A user with a `uint8` image cannot use the library at all. `extract` raises
28
+ `NotImplementedError: "im2col_out_cpu" not implemented for 'Byte'`, and `reconstruct` and
29
+ `stitch` reject every non-floating-point dtype outright. The only route is `.float()`
30
+ first, which on an 8192x8192 tile at patch 512 stride 256 turns a 721 MiB patch stack into
31
+ 2883 MiB.
32
+
33
+ The owner's framing of the problem is the right one and worth quoting, because it rules out
34
+ the easy answer: `input(byte) -> operation(???) -> output(byte)` is not the same thing as
35
+ `input(byte) -> operation(byte) -> output(byte)`. Returning the input dtype after widening
36
+ internally gives back neither the memory nor the time.
37
+
38
+ ### Measurements that shaped the design
39
+
40
+ All taken on 2026-09-03, CPU, torch 2.14.0+cpu, on the machine named in
41
+ [`PERFORMANCE.md`](../PERFORMANCE.md).
42
+
43
+ **Byte-native averaging is exact, and it is the fastest option.** The identity
44
+ `sum(a_i)//k == sum(a_i//k) + sum(a_i mod k)//k` holds for every `k`, verified exhaustively
45
+ for k in 2..5 and over 200,000 random samples up to k=16. The quotient accumulator always
46
+ fits `uint8`; the remainder accumulator holds `k*(k-1)` and so fits `uint8` only for k <= 16.
47
+ At k=4 over 3.1M pixels: 2.51 ms byte-native, 4.24 ms widening to `int16`, 6.19 ms via
48
+ `float32`, with accumulators of 3.0, 6.0 and 12.0 MiB.
49
+
50
+ **A first measurement of this said the opposite, and the error is instructive.** Writing
51
+ the byte path as `(x // k).sum(0)` measured 9x slower than float. `torch`'s `uint8.sum()`
52
+ promotes to `int64` silently, so that benchmark was the very
53
+ `input(byte) -> operation(???)` shape the design exists to avoid. Only accumulators declared
54
+ `uint8` measure the thing.
55
+
56
+ **What decides speed is not integer versus float. It is whether the divisor is a power of
57
+ two.** The same arithmetic, the same k, written with `//` and `%` against `>>` and `&`:
58
+
59
+ | k | via `//` and `%` | via `>>` and `&` | ratio |
60
+ |---|---|---|---|
61
+ | 2 | 22.51 ms | 1.39 ms | 16.1x |
62
+ | 4 | 40.17 ms | 2.53 ms | 15.9x |
63
+ | 8 | 76.46 ms | 5.09 ms | 15.0x |
64
+ | 16 | 148.71 ms | 9.71 ms | 15.3x |
65
+
66
+ x86 has no SIMD integer division. A power-of-two divisor turns it into a shift, which
67
+ vectorises.
68
+
69
+ **The same idea generalises to floats, and fixes a defect the library papered over.**
70
+ THEORY §9.2 records that a constant `float16` image at 10000.0 with `ph=3, stride=1`
71
+ returned `inf` in 144 of 256 pixels, and that `float32` promotion was added to prevent it.
72
+ Reproduced exactly. Scaling each patch by `1/k` before the fold keeps the numerator at
73
+ 1.0e+04 against a finite `float16` maximum of 6.55e+04, produces no `inf`, and returns the
74
+ image with zero error, in a 2 byte per pixel accumulator instead of 4. On ordinary [0, 1]
75
+ data it costs 5.96e-08. On this machine it is nonetheless 1.35x slower than the promotion,
76
+ because torch's CPU `float16` kernels are weak. The advantage is real but it is per-dtype
77
+ and must be measured, never assumed.
78
+
79
+ **`bfloat16` is in the wrong bucket.** THEORY §9.2 justifies promoting both half formats by
80
+ `float16` overflowing its finite maximum of 65504, then applies the rule to `bfloat16` too.
81
+ `bfloat16` carries `float32`'s exponent: on the same case its numerator reached 9.01e+04
82
+ with no `inf` and a finite maximum of 3.39e+38. Promoting it buys precision, since it has
83
+ 7 mantissa bits against `float16`'s 10, not range. The stated reason covers one of the two.
84
+
85
+ ## Decision
86
+
87
+ Three fine-grained knobs, each naming one existing policy, plus one preset under a hard
88
+ constraint. **Nothing here is implemented. This ADR records the design and the order.**
89
+
90
+ ### The knobs
91
+
92
+ | Name | Type | Values | Default | Governs | Borrowed from |
93
+ |---|---|---|---|---|---|
94
+ | `accum_dtype` | `torch.dtype \| None`, keyword-only | any dtype at least as wide as the input, of the same kind | `None`, meaning today's rule | where the numerator and count map live in `reconstruct` and `stitch`. Does not change the return dtype. | `numpy.sum(dtype=)`, documented as "the accumulator in which the elements are summed", and `torch.sum(dtype=)` |
95
+ | `rounding_mode` | `Literal["trunc","floor","nearest_even"] \| None` | as listed | `None` | the single rounding event on `stitch`'s final divide, once `stitch` accepts integers. Not offered on `reconstruct`. | `torch.div(rounding_mode=)`, same name, two of three values; the tie-break vocabulary from stdlib `decimal` |
96
+ | `antialias` | `bool \| None` | `True`, `False`, `None` | `None`, meaning whatever the backend does today | whether the torch `resize` backend prefilters before downsampling | `torch.nn.functional.interpolate` and `torchvision.transforms.v2.functional.resize`, verbatim |
97
+
98
+ `None` is a sentinel on every one of them, never a spelled-out default. Without it a preset
99
+ cannot distinguish "unspecified" from "explicitly asked for the current value", and the
100
+ resolver has nothing to key on.
101
+
102
+ **Not `accumulate=`.** It is a verb where every precedent is a noun, it does not say the
103
+ value is a dtype, and `numpy` already owns the identifier for `ufunc.accumulate`, which is a
104
+ running scan and a different operation.
105
+
106
+ **No `rounding_mode` on `reconstruct`.** For unmodified patches every covering value is the
107
+ same `v`, so the sum is exactly `k*v` and `k*v // k == v` for every `k`, in every dtype.
108
+ `reconstruct` does not round. A knob there would advertise a decision the function does not
109
+ make.
110
+
111
+ ### The preset
112
+
113
+ `effort: Literal["balanced", "fast"] | None = None`, keyword-only, on `reconstruct`,
114
+ `stitch`, `extract` and `Patchify.__init__`. `Patchify` is the reason it exists: inside a
115
+ `Compose` there is no per-call keyword, so a stored string is the only way a policy reaches
116
+ a data pipeline.
117
+
118
+ One axis, following x264 and x265, whose presets are all speed and whose quality is a
119
+ separate orthogonal knob. Not `fast`/`quality`/`precise`: that trio spans three axes and
120
+ admits no correct ordering, and it leaves the caller who wants exact-and-fast, which is the
121
+ common case here, nothing to pass. No superlative at either end, so a third rung can be
122
+ added later without a lie, which is the wall both `torch` ("highest") and JAX (`HIGHEST`)
123
+ hit.
124
+
125
+ **The constraint, and it is the whole decision:**
126
+
127
+ > `effort=` may change speed and memory. It may never change a returned value. Anything
128
+ > that changes values requires an explicit knob.
129
+
130
+ That constraint is what separates this from `-ffast-math`, which is structurally the same
131
+ proposal: a name promising speed whose effect changes results. Its failure modes are all
132
+ reachable here in miniature. It deletes the caller's own safety checks, a compiler update
133
+ silently reinterpreted it more aggressively and broke shipping software with no source
134
+ change, and it escapes the caller's scope entirely by setting process-wide floating point
135
+ mode. The constraint also makes the preset falsifiable, which is what this project's
136
+ contract style demands: "fast" is unprovable, but "`effort="fast"` is bit-identical to
137
+ `effort="balanced"`" is a test id.
138
+
139
+ `effort="fast"` may therefore expand only into: permitting the native accelerator without
140
+ an environment variable, which structurally replaces the `PATCHCRAFT_ACCEL` global that is
141
+ already an unnamed preset today; permitting the shift-and-mask integer path where the
142
+ coverage map makes it bit-identical to the divide; and returning views where aliasing is
143
+ provably safe, which is a memory choice and needs its own sub-knob rather than hiding here.
144
+ `effort="balanced"` expands to nothing at all: it is today's behaviour, exhaustively.
145
+
146
+ Ship `patchcraft.effort_options(name) -> dict` returning the literal expansion, after
147
+ `torch._inductor.list_mode_options`, so the preset is never a black box and the knobs stay
148
+ the source of truth.
149
+
150
+ **Precedence: the preset seeds defaults, explicit keyword arguments override, and argument
151
+ order is irrelevant.** After x265, which promises exactly that in prose. Not mutual
152
+ exclusion: `torch.compile` raises on preset-plus-options and the bill is visible, because
153
+ `max-autotune-no-cudagraphs` exists as a whole extra public name purely to work around it.
154
+
155
+ ### Amendment to ADR 0003
156
+
157
+ **The exactness predicate is a property of the geometry *and the accumulator*, not of the
158
+ geometry alone.** ADR 0003 states the round trip is bit-exact iff every coverage count is a
159
+ power of two. Measured over 76 legal square geometries on `float32` input: with a `float32`
160
+ accumulator, 45 of 76 are exact, and those 45 are exactly the power-of-two geometries, so
161
+ the predicate is perfectly predictive. With a `float64` accumulator, **76 of 76 are exact**.
162
+
163
+ The predicate as written carries an unstated premise, which is the accumulator the library
164
+ happens to use today. ADR 0003 should say so. It also means `accum_dtype=torch.float64`
165
+ would offer unconditional bit-exactness for `float32` input at twice the accumulator memory,
166
+ which is a stronger guarantee than the library currently advertises.
167
+
168
+ **And the predicate does not govern integers at all.** A `uint8` round trip through an
169
+ `int32` accumulator was exact on all six geometries tested, including three whose count maps
170
+ are not powers of two, for the reason given above: the sum is exactly `k*v`. Gating an
171
+ integer path on the float predicate would be a category error.
172
+
173
+ ## Consequences
174
+
175
+ ### Effort, each step independently shippable
176
+
177
+ | Step | Size | Breaks frozen surface | When |
178
+ |---|---|---|---|
179
+ | 0. Record this ADR, and qualify the four copies of "widening the dtype does not help" | 0.5 to 1 day | no | before 1.0, and it is the only item that must be |
180
+ | A. Fix three defects as bugs, no knob | done, 2026-09-03 | no signature change | done before 1.0, as planned |
181
+ | 1. `extract` accepts the five integer dtypes, by adding them to the fast-path set | ~1 day | no | after 1.0, never alone |
182
+ | 2. Exact integer `reconstruct`, unrestricted, `int32` accumulator, every legal geometry | 4 to 6 days | no signature change | after 1.0, shipped with step 1 as one 1.1 |
183
+ | 3. Byte-native `uint8` quotient-and-remainder path | 2 to 3 days | no | do not build yet: 1.7x over an `int32` accumulator, at the cost of a second, different predicate on the doc surface |
184
+ | 4. `accum_dtype=` on `reconstruct` and `stitch` | 1 day, 2 alongside step 2 | yes, signature | after 1.0 |
185
+ | 5. `rounding_mode=` on `stitch` | 1 day | yes, signature | after 1.0, and only if a real caller appears |
186
+ | 6. `effort=` and `effort_options()` | 1 day of code, 3+ of contract | yes, signature, four sites | after 1.0, after 1 and 2 have shipped |
187
+ | 7. Bool support, where the inverse is a majority vote | 3 to 4 days | it is a 21st public name | 1.1 at the earliest, a function rather than a parameter |
188
+
189
+ ### Order
190
+
191
+ Record the design. Fix the three defects as bugs on their own merits, because folding them
192
+ into a `precise` tier would sell the absence of a bug as a quality level. **Step A landed on
193
+ 2026-09-03**, and the three were the `stitch` denominator floor, the `reconstruct`
194
+ single-patch aliasing and the `resize` inbound clamp on a dtype that cannot hold the bound.
195
+ The remaining items named in the audit, the `bfloat16` justification in THEORY 9.2 and the
196
+ resize antialias gap, are still open and are not defects of the same kind: the first is a
197
+ documentation correction and the second is a user-facing choice that needs its own release. Document, at zero
198
+ cost, the rounding policy already in force. Freeze 1.0 at 20 names with no new parameters.
199
+ Then 1.1 brings steps 1 and 2 together and unrestricted. Then stop and wait for a user:
200
+ only if one appears do the knobs follow, and the preset last of all.
201
+
202
+ ### What not to do
203
+
204
+ - Do not gate the integer path on the power-of-two predicate. It is a float predicate.
205
+ - Do not let any preset change a returned value.
206
+ - Do not ship step 1 without step 2. `extract` accepting `uint8` while `reconstruct` rejects
207
+ it makes THEORY §9.1 and §9.2 false as the matched pair they are written as.
208
+ - Do not make any of this a module global, an environment variable, or a context manager
209
+ that mutates torch state.
210
+ - Do not offer `rounding="nearest"` without saying which tie-break.
211
+ - Do not add a knob to `accel_available()`. It is one of the 20 frozen names with a pinned
212
+ zero-argument signature.
213
+
214
+ ### Open questions, for the decision this ADR defers
215
+
216
+ 1. Does `accum_dtype` change only where the sum lives, or also what comes back?
217
+ `reconstruct` casts back to `patches.dtype`; `metrics` returns `float64` unconditionally.
218
+ The two are inconsistent today and the knob would expose it.
219
+ 2. If `accum_dtype=float64` makes every geometry exact, does the headline contract become
220
+ "exact, at this accumulator width" rather than "exact on these geometries"? That reads
221
+ as a stronger promise and needs its own falsification suite before it is made.
222
+ 3. Are steps 1 to 3 a `0.Y.0` or a `z` bump? The project shipped the mirror-image change
223
+ once: 0.2.1 added the `reconstruct` integer guard, turning a raw `NotImplementedError`
224
+ into a framed `ValueError`.
225
+ 4. Integers with `dilation > 1`: a framed `ValueError`, or torch's raw error in that corner?
226
+ 5. CUDA. Everything here was measured on CPU, and the accelerator refuses non-CPU tensors
227
+ outright. No accept text may say "integers work" without a device qualifier.
@@ -812,7 +812,7 @@ Five things this project does not claim.
812
812
 
813
813
  **5. The accelerated and the pure paths are equal by test, on two platforms out of five.** Every geometry in the suite is checked with `torch.equal` against the pure path, but that job runs on Ubuntu and Windows x86_64 only. The macOS and aarch64 wheels are built and their contents are checked, and nothing has ever executed their kernel in CI.
814
814
 
815
- **6. Pre-1.0 means output values can change when the middle digit moves.** One release rewrote the hann window, so `stitch(..., weight="hann")` returns different values than the release before it did for every geometry, and the round-trip contract did not change. That is the kind of change a new `0.y` is allowed to carry and a new `0.y.z` is not. [CHANGELOG.md](../CHANGELOG.md) records each one together with the measurement that motivated it.
815
+ **6. Pre-1.0 means output values can change in either kind of release, for two different reasons.** A new `0.y` may move behaviour the documentation endorsed, which is what happened when `tilings` began returning 73 specs where it returned 100. A new `0.y.z` may change values too, but only by making the code match a promise the documentation was already making: twice now that has been the `stitch` hann denominator, once for a window that zeroed covered pixels and once for a floor that swallowed the corner band. [CHANGELOG.md](../CHANGELOG.md) records each one together with the measurement that motivated it, and [CONTRIBUTING.md](../CONTRIBUTING.md) carries the rule.
816
816
 
817
817
  The backlog that closes these is [FOCO-1.0.md](FOCO-1.0.md).
818
818
 
@@ -855,7 +855,7 @@ Installing from the sdist compiles the accelerator when a Rust toolchain is pres
855
855
  author = {Souza, Leonardo Marques de},
856
856
  title = {PatchCraft: image patch extraction, reconstruction, pairing
857
857
  and seam-aware stitching},
858
- version = {0.5.1}, % check CHANGELOG.md for the release you used
858
+ version = {0.5.2}, % check CHANGELOG.md for the release you used
859
859
  year = {2026},
860
860
  url = {https://github.com/LeoPR/PatchCraft}
861
861
  }
@@ -111,9 +111,9 @@ Visualizing the count map along one axis (image cols 0..7, `ph=4`, `sh=2`):
111
111
 
112
112
  **Dilation.** `F.fold` does not support dilation in the same way as `F.unfold`: for `d > 1` the patch footprint skips pixels, and `fold` would deposit the sparse contributions into a canvas that is not the image. Rather than building a custom scatter, we refuse `dilation != 1` at reconstruction time with a clear `ValueError`. Callers who extracted with dilation are expected to consume patches directly (e.g., as features) and not round-trip.
113
113
 
114
- **Stride maior que patch (lacunas).** Quando `sh > ph` ou `sw > pw`, o grid pula pixels: a soma `fold(...)` tem zeros nessas posições, e qualquer divisão (incluindo pelo clamp `min=1e-6`) produz pixels com valor arbitrário, ou seja, síntese de dado, exatamente o que §1 proíbe ao escolher truncamento como única política de borda. Recusamos a condição com `ValueError` logo na entrada de `reconstruct`. Consumidores que querem features esparsas (kernels, classificadores) usam apenas `extract`, onde `stride > patch_size` é aceito sem objeções, e não tentam round-trip.
114
+ **Stride maior que patch (lacunas).** Quando `sh > ph` ou `sw > pw`, o grid pula pixels: a soma `fold(...)` tem zeros nessas posições, e qualquer divisão (incluindo pelo piso do denominador) produz pixels com valor arbitrário, ou seja, síntese de dado, exatamente o que §1 proíbe ao escolher truncamento como única política de borda. Recusamos a condição com `ValueError` logo na entrada de `reconstruct`. Consumidores que querem features esparsas (kernels, classificadores) usam apenas `extract`, onde `stride > patch_size` é aceito sem objeções, e não tentam round-trip.
115
115
 
116
- **Design decision.** `patchcraft.reconstruct(patches, image_shape, stride, dilation=1) -> Tensor[C, H, W]` uses `F.fold` followed by division by the overlap-count map (computed once, same-geometry fold of ones). Raises `ValueError` when `dilation != 1` **ou quando `sh > ph` ou `sw > pw`** (cobertura parcial é proibida). `image_shape` is `(C, H, W)` and must match the geometry implied by the patch grid; inconsistent shapes raise `ValueError`. The count map clamp (`min=1e-6`) existe apenas para absorver ruído float em pixels totalmente cobertos, nunca para mascarar buracos de cobertura. Output dtype matches input.
116
+ **Design decision.** `patchcraft.reconstruct(patches, image_shape, stride, dilation=1) -> Tensor[C, H, W]` uses `F.fold` followed by division by the overlap-count map (computed once, same-geometry fold of ones). Raises `ValueError` when `dilation != 1` **ou quando `sh > ph` ou `sw > pw`** (cobertura parcial é proibida). `image_shape` is `(C, H, W)` and must match the geometry implied by the patch grid; inconsistent shapes raise `ValueError`. O piso do denominador existe apenas para absorver ruído float em pixels totalmente cobertos, nunca para mascarar buracos de cobertura; desde 0.5.2 ele é `finfo(dtype).tiny`, que nunca alcança um peso legítimo (o menor canto hann 2-D, mesmo em patch 1024, é 8,8e-11). Output dtype matches input.
117
117
 
118
118
  ## 2.5 Stitching modified patches
119
119
 
@@ -129,7 +129,7 @@ The standard trick is to weight each patch's contribution by a 2-D window whose
129
129
 
130
130
  For *unmodified* patches, every `patch_k(i_k, j_k)` equals `img(x, y)` (definition of `extract`), so the numerator factors as `img(x, y) · denominator(x, y)`, and the output is `img(x, y)` exactly, so round-trip is preserved for any kernel as long as the denominator at `(x, y)` is positive. For *modified* patches, contributions are weighted by how central `(x, y)` is to each contributing patch, so seams are attenuated.
131
131
 
132
- **Implementation.** Two `F.fold` calls: one on the weighted patches (numerator), one on the kernel replicated across the `L` patch slots (denominator). Identical geometry to `reconstruct`'s count-map fold; same `clamp(min=1e-6)` on the denominator to absorb float noise.
132
+ **Implementation.** Two `F.fold` calls: one on the weighted patches (numerator), one on the kernel replicated across the `L` patch slots (denominator). Identical geometry to `reconstruct`'s count-map fold; the denominator carries the same floor. Since 0.5.2 that floor is `torch.finfo(dtype).tiny` and no longer an absolute `1e-6`: the 2-D hann corner weight is `(π/(n+1))⁴`, which falls below `1e-6` at patch 99, so every larger hann stitch had its corner band divided by the floor instead of by the real weight. Medido a 640×640, patch 256, stride 128: erro máximo 0,94 em dado [0, 1] e 960 pixels errados, idêntico em float64 porque a causa era a constante e não a precisão.
133
133
 
134
134
  **Window kernels.**
135
135
 
@@ -319,6 +319,7 @@ Retorna `PatchPair(lr_patches, hr_patches, metas)` (frozen dataclass com `__slot
319
319
  - `resample=None` → default por backend (LANCZOS pra PIL, bilinear pra torch).
320
320
  - 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).
321
321
  - 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).
322
+ - 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.
322
323
 
323
324
  **Rejeita (`ValueError`):**
324
325
  - `Tensor` em CUDA com `backend == "pil"` (PIL não enxerga GPU, então exige `.cpu()` explícito do caller).
@@ -439,7 +440,7 @@ The blending counterpart to `reconstruct`. Same fold geometry, same rejections;
439
440
  - `dtype` and `device` preserved on output.
440
441
 
441
442
  **Sinaliza (não exceção):**
442
- - (nenhuma condição sinalizada; as três janelas são estritamente positivas desde 0.2.1, então nenhum pixel coberto é zerado pela janela)
443
+ - (nenhuma condição sinalizada; as três janelas são estritamente positivas desde 0.2.1, então nenhum pixel coberto é zerado pela janela. Até 0.5.1 essa frase era verdadeira quanto à janela e falsa quanto ao resultado, porque o piso absoluto `1e-6` do denominador corrompia a banda de canto a partir de patch 99; corrigido em 0.5.2.)
443
444
 
444
445
  **Rejeita (`ValueError` / `TypeError`):**
445
446
  - `patches` not a tensor (`TypeError`).
@@ -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.5.1'
22
- __version_tuple__ = version_tuple = (0, 5, 1)
21
+ __version__ = version = '0.5.2'
22
+ __version_tuple__ = version_tuple = (0, 5, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g375362d3b'
24
+ __commit_id__ = commit_id = 'g6090abd8c'
@@ -85,7 +85,14 @@ def reconstruct(
85
85
  # reconstruction is a pure rearrangement -- no fold, no count map, and
86
86
  # no widening for half precision (nothing accumulates).
87
87
  grid = patches.reshape(num_h, num_w, c, ph, pw)
88
- return grid.permute(2, 0, 3, 1, 4).reshape(c, h, w)
88
+ out = grid.permute(2, 0, 3, 1, 4).reshape(c, h, w)
89
+ if out.untyped_storage().data_ptr() == patches.untyped_storage().data_ptr():
90
+ # Single-patch grid: the permuted view is already contiguous, so
91
+ # reshape aliased the caller's patches and a later write to them
92
+ # would change an image this function already returned. `extract`
93
+ # guards the mirror image of this case.
94
+ out = out.clone()
95
+ return out
89
96
 
90
97
  # Half-precision inputs overflow inside F.fold, which accumulates the sum
91
98
  # of all overlapping patches before the count-map division (fp16 max is
@@ -57,8 +57,11 @@ def _tensor_to_pil_u8(tensor: torch.Tensor) -> PILImage:
57
57
  else:
58
58
  # Integer tensors: clamp to the uint8 range before casting, otherwise
59
59
  # values outside [0, 255] wrap (300 -> 44) -- same defect class as the
60
- # 0.2.0 bicubic overshoot wrap on the torch backend.
61
- arr = tensor.clamp(0, 255).cpu().numpy().astype(np.uint8)
60
+ # 0.2.0 bicubic overshoot wrap on the torch backend. Widen first,
61
+ # because the bound itself has to be representable: `clamp(0, 255)` on
62
+ # an int8 tensor raised `value cannot be converted to type int8_t
63
+ # without overflow` before clamping anything.
64
+ arr = tensor.to(torch.int32).clamp(0, 255).cpu().numpy().astype(np.uint8)
62
65
  c = arr.shape[0]
63
66
  if c == 1:
64
67
  return Image.fromarray(arr[0], mode="L")
@@ -184,7 +187,13 @@ def resize(
184
187
  pil_out = _resize_pil(pil_in, target_size, resample)
185
188
  tensor_out = _pil_to_tensor_f32(pil_out)
186
189
  if not original_dtype.is_floating_point:
187
- tensor_out = (tensor_out * 255).round()
190
+ # Round AND clamp, the same pair the torch branch applies.
191
+ # Without the clamp a narrow signed dtype cannot represent the
192
+ # 0..255 range the PIL hop produces, and the cast raised a raw
193
+ # `RuntimeError: value cannot be converted to type int8_t
194
+ # without overflow` instead of returning a saturated value.
195
+ info = torch.iinfo(original_dtype)
196
+ tensor_out = (tensor_out * 255).round().clamp(info.min, info.max)
188
197
  return tensor_out.to(original_dtype)
189
198
  return _resize_torch(image, target_size, resample)
190
199
 
@@ -223,7 +223,13 @@ def stitch(
223
223
  den_w = _fold_window_1d(ww, w, num_w, sw)
224
224
  den = den_h.unsqueeze(1) * den_w.unsqueeze(0)
225
225
 
226
- # clamp(min=1e-6): geometry validation guarantees coverage and all three
227
- # windows are strictly positive, so the denominator is genuinely
228
- # positive; the clamp is a defensive no-op kept from the fold version.
229
- return (numerator / den.clamp(min=1e-6)).to(patches.dtype)
226
+ # The floor is the dtype's own smallest normal, not an absolute constant.
227
+ # Geometry validation guarantees coverage and all three windows are
228
+ # strictly positive, so the denominator is genuinely positive and this
229
+ # only guards a true zero. An absolute 1e-6 was not the no-op its comment
230
+ # claimed: the 2-D hann corner weight is (pi/(n+1))**4, which falls below
231
+ # 1e-6 at patch 99, so every larger hann stitch had its corner band
232
+ # divided by 1e-6 instead of by the real weight. Measured at 640x640,
233
+ # patch 256, stride 128: max error 0.94 on data in [0, 1], 960 pixels
234
+ # wrong, identical in float64 because it was never a precision problem.
235
+ return (numerator / den.clamp_min(torch.finfo(den.dtype).tiny)).to(patches.dtype)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: patchcraft
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Image patch extraction, reconstruction, pairing and seam-aware stitching for super-resolution and dataset pipelines.
5
5
  Author: Leonardo Marques de Souza
6
6
  License-Expression: MIT
@@ -26,13 +26,11 @@ docs/USAGE.md
26
26
  docs/ADR/0001-patch-extraction-api.md
27
27
  docs/ADR/0002-patchify-transform.md
28
28
  docs/ADR/0003-reversibility-classes.md
29
+ docs/ADR/0004-precision-and-effort-parameters.md
29
30
  docs/STUDIES/2026-08-04-patch-techniques.md
30
- docs/superpowers/plans/2026-09-01-fase1-bugs-performance.md
31
- docs/superpowers/plans/2026-09-01-fase2-acel.md
32
- docs/superpowers/plans/2026-09-01-fase3-g1-predicado.md
33
- docs/superpowers/specs/2026-09-01-fase1-bugs-performance-design.md
34
- docs/superpowers/specs/2026-09-01-fase2-acel-design.md
35
- docs/superpowers/specs/2026-09-01-fase3-g1-predicado-design.md
31
+ docs/design/2026-09-01-fase1-bugs-performance.md
32
+ docs/design/2026-09-01-fase2-acelerador.md
33
+ docs/design/2026-09-01-fase3-predicado.md
36
34
  src/patchcraft/__init__.py
37
35
  src/patchcraft/_accel.py
38
36
  src/patchcraft/_foldgeom.py