patchcraft 0.2.0__tar.gz → 0.2.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 (58) hide show
  1. {patchcraft-0.2.0 → patchcraft-0.2.2}/.github/workflows/release.yml +128 -111
  2. {patchcraft-0.2.0 → patchcraft-0.2.2}/.github/workflows/test.yml +10 -2
  3. patchcraft-0.2.2/CHANGELOG.md +340 -0
  4. patchcraft-0.2.2/CONTRIBUTING.md +98 -0
  5. patchcraft-0.2.2/PKG-INFO +155 -0
  6. patchcraft-0.2.2/README.md +138 -0
  7. patchcraft-0.2.2/README.pt-BR.md +138 -0
  8. patchcraft-0.2.2/README.pypi.md +115 -0
  9. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ADR/0001-patch-extraction-api.md +100 -100
  10. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ADR/0002-patchify-transform.md +101 -101
  11. patchcraft-0.2.2/docs/ADR/0003-reversibility-classes.md +107 -0
  12. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/AUXILIARY.md +169 -167
  13. patchcraft-0.2.2/docs/FOCO-1.0.md +110 -0
  14. patchcraft-0.2.2/docs/GUIDE.md +848 -0
  15. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ROADMAP.md +111 -94
  16. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/SCOPE.md +293 -282
  17. patchcraft-0.2.2/docs/STUDIES/2026-08-04-patch-techniques.md +190 -0
  18. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/THEORY.md +460 -451
  19. {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/USAGE.md +491 -482
  20. {patchcraft-0.2.0 → patchcraft-0.2.2}/pyproject.toml +100 -89
  21. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/__init__.py +40 -39
  22. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/cache.py +3 -3
  23. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/extract.py +1 -1
  24. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/geometry.py +290 -290
  25. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/metrics.py +159 -151
  26. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/pair.py +166 -161
  27. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/reconstruct.py +155 -118
  28. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/resize.py +14 -1
  29. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/stitch.py +239 -215
  30. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/_datasets.py +117 -117
  31. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/conftest.py +1 -1
  32. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_cache.py +254 -254
  33. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_extract.py +250 -250
  34. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_geometry.py +333 -333
  35. patchcraft-0.2.2/tests/test_import.py +14 -0
  36. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_metrics.py +213 -170
  37. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_pair.py +241 -212
  38. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_reconstruct.py +262 -205
  39. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_resize.py +229 -187
  40. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_stitch.py +283 -229
  41. patchcraft-0.2.2/uv.lock +1041 -0
  42. patchcraft-0.2.0/.vscode/settings.json +0 -3
  43. patchcraft-0.2.0/CHANGELOG.md +0 -175
  44. patchcraft-0.2.0/PKG-INFO +0 -266
  45. patchcraft-0.2.0/README.md +0 -226
  46. patchcraft-0.2.0/lab/.gitignore +0 -3
  47. patchcraft-0.2.0/lab/2026-05-16-roundtrip-mnist.py +0 -80
  48. patchcraft-0.2.0/lab/README.md +0 -34
  49. patchcraft-0.2.0/lab/usage_demo.out +0 -174
  50. patchcraft-0.2.0/lab/usage_demo.py +0 -239
  51. patchcraft-0.2.0/tests/test_import.py +0 -7
  52. patchcraft-0.2.0/uv.lock +0 -1010
  53. {patchcraft-0.2.0 → patchcraft-0.2.2}/.gitignore +0 -0
  54. {patchcraft-0.2.0 → patchcraft-0.2.2}/.python-version +0 -0
  55. {patchcraft-0.2.0 → patchcraft-0.2.2}/LICENSE +0 -0
  56. {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/py.typed +0 -0
  57. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/__init__.py +0 -0
  58. {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_datasets_helper.py +0 -0
@@ -1,111 +1,128 @@
1
- name: release
2
-
3
- # Triggered when a version tag is pushed (vX.Y.Z[.aN, .bN, .rcN]).
4
- # Steps:
5
- # 1. Re-run the test job that test.yml uses (sanity check on the
6
- # exact ref being released).
7
- # 2. Build the wheel + sdist with `uv build`.
8
- # 3. Publish to PyPI via Trusted Publishing (OIDC) -- no token,
9
- # no secret. The pypi.org side must have a configured publisher
10
- # pointing at THIS repo + THIS workflow + the `pypi` environment.
11
- # 4. Create / update the GitHub Release page and attach the
12
- # `dist/*.whl` and `dist/*.tar.gz` files.
13
-
14
- on:
15
- push:
16
- tags:
17
- - "v[0-9]+.[0-9]+.[0-9]+"
18
- - "v[0-9]+.[0-9]+.[0-9]+.[ab][0-9]+"
19
- - "v[0-9]+.[0-9]+.[0-9]+.rc[0-9]+"
20
-
21
- permissions:
22
- contents: read
23
-
24
- jobs:
25
- validate:
26
- name: re-run tests at tag ref
27
- runs-on: ubuntu-latest
28
- steps:
29
- - uses: actions/checkout@v4
30
- - uses: astral-sh/setup-uv@v3
31
- with:
32
- enable-cache: true
33
- - run: uv python install 3.13
34
- - run: uv sync --extra cache --extra dev --python 3.13
35
- - run: uv run ruff check src tests
36
- - run: uv run mypy --strict src
37
- - run: uv run pytest -m "not gpu"
38
-
39
- build:
40
- name: build wheel + sdist
41
- runs-on: ubuntu-latest
42
- needs: validate
43
- steps:
44
- - uses: actions/checkout@v4
45
- - uses: astral-sh/setup-uv@v3
46
- - run: uv build
47
- - name: Inspect dist metadata
48
- run: |
49
- uv run --with twine twine check dist/*
50
- ls -la dist/
51
- - uses: actions/upload-artifact@v4
52
- with:
53
- name: dist
54
- path: dist/
55
-
56
- publish-pypi:
57
- name: publish to PyPI (Trusted Publishing)
58
- runs-on: ubuntu-latest
59
- needs: build
60
- environment:
61
- name: pypi
62
- url: https://pypi.org/project/patchcraft/
63
- permissions:
64
- id-token: write # required for OIDC token exchange with PyPI
65
- steps:
66
- - uses: actions/download-artifact@v4
67
- with:
68
- name: dist
69
- path: dist/
70
- - name: Publish via Trusted Publishing
71
- uses: pypa/gh-action-pypi-publish@release/v1
72
- # No `password` field -- OIDC handles authentication.
73
- # The pypi.org "Pending publisher" config must match:
74
- # PyPI project name: patchcraft
75
- # Owner: LeoPR
76
- # Repository: PatchCraft
77
- # Workflow: release.yml
78
- # Environment: pypi
79
-
80
- github-release:
81
- name: create GitHub Release with assets
82
- runs-on: ubuntu-latest
83
- needs: publish-pypi
84
- permissions:
85
- contents: write # required to create a Release
86
- steps:
87
- - uses: actions/checkout@v4
88
- - uses: actions/download-artifact@v4
89
- with:
90
- name: dist
91
- path: dist/
92
- - name: Extract version from tag
93
- id: ver
94
- run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
95
- - name: Create Release + attach assets
96
- uses: softprops/action-gh-release@v2
97
- with:
98
- name: "PatchCraft ${{ github.ref_name }}"
99
- body: |
100
- See [`CHANGELOG.md`](https://github.com/LeoPR/PatchCraft/blob/main/CHANGELOG.md) for the full notes.
101
-
102
- Install:
103
- ```
104
- pip install patchcraft==${{ steps.ver.outputs.version }}
105
- ```
106
- files: |
107
- dist/*.whl
108
- dist/*.tar.gz
109
- fail_on_unmatched_files: true
110
- draft: false
111
- prerelease: false
1
+ name: release
2
+
3
+ # Triggered when a version tag is pushed (vX.Y.Z[.aN, .bN, .rcN]).
4
+ # Steps:
5
+ # 1. Re-run the test job that test.yml uses (sanity check on the
6
+ # exact ref being released).
7
+ # 2. Build the wheel + sdist with `uv build`.
8
+ # 3. Publish to PyPI via Trusted Publishing (OIDC) -- no token,
9
+ # no secret. The pypi.org side must have a configured publisher
10
+ # pointing at THIS repo + THIS workflow + the `pypi` environment.
11
+ # 4. Create / update the GitHub Release page and attach the
12
+ # `dist/*.whl` and `dist/*.tar.gz` files.
13
+
14
+ on:
15
+ push:
16
+ tags:
17
+ - "v[0-9]+.[0-9]+.[0-9]+"
18
+ - "v[0-9]+.[0-9]+.[0-9]+.[ab][0-9]+"
19
+ - "v[0-9]+.[0-9]+.[0-9]+.rc[0-9]+"
20
+
21
+ permissions:
22
+ contents: read
23
+
24
+ jobs:
25
+ validate:
26
+ name: re-run tests at tag ref
27
+ runs-on: ubuntu-latest
28
+ env:
29
+ UV_PYTHON: "3.13"
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: astral-sh/setup-uv@v3
33
+ with:
34
+ enable-cache: true
35
+ - run: uv python install
36
+ - run: uv sync --locked --extra cache --extra dev
37
+ - name: Tag must match __version__
38
+ run: |
39
+ tag="${GITHUB_REF_NAME#v}"
40
+ pkg="$(uv run python -c 'import patchcraft; print(patchcraft.__version__)')"
41
+ if [ "$tag" != "$pkg" ]; then
42
+ echo "::error::tag ${GITHUB_REF_NAME} implies version '${tag}' but patchcraft.__version__ is '${pkg}'"
43
+ exit 1
44
+ fi
45
+ echo "tag ${GITHUB_REF_NAME} matches __version__ ${pkg}"
46
+ - run: uv run ruff check src tests
47
+ - run: uv run mypy --strict src
48
+ - run: uv run pytest -m "not gpu"
49
+
50
+ build:
51
+ name: build wheel + sdist
52
+ runs-on: ubuntu-latest
53
+ needs: validate
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+ - uses: astral-sh/setup-uv@v3
57
+ - run: uv build
58
+ - name: Inspect dist metadata
59
+ run: |
60
+ uv run --with twine twine check dist/*
61
+ ls -la dist/
62
+ - uses: actions/upload-artifact@v4
63
+ with:
64
+ name: dist
65
+ path: dist/
66
+
67
+ publish-pypi:
68
+ name: publish to PyPI (Trusted Publishing)
69
+ runs-on: ubuntu-latest
70
+ needs: build
71
+ environment:
72
+ name: pypi
73
+ url: https://pypi.org/project/patchcraft/
74
+ permissions:
75
+ id-token: write # required for OIDC token exchange with PyPI
76
+ steps:
77
+ - uses: actions/download-artifact@v4
78
+ with:
79
+ name: dist
80
+ path: dist/
81
+ - name: Publish via Trusted Publishing
82
+ uses: pypa/gh-action-pypi-publish@release/v1
83
+ with:
84
+ # A version already on PyPI is a no-op instead of a hard failure.
85
+ # Without this, re-running the pipeline (or tagging a version that
86
+ # was uploaded by hand) fails here, and `github-release` never runs
87
+ # because it is `needs: publish-pypi`.
88
+ skip-existing: true
89
+ # No `password` field -- OIDC handles authentication.
90
+ # The pypi.org "Pending publisher" config must match:
91
+ # PyPI project name: patchcraft
92
+ # Owner: LeoPR
93
+ # Repository: PatchCraft
94
+ # Workflow: release.yml
95
+ # Environment: pypi
96
+
97
+ github-release:
98
+ name: create GitHub Release with assets
99
+ runs-on: ubuntu-latest
100
+ needs: publish-pypi
101
+ permissions:
102
+ contents: write # required to create a Release
103
+ steps:
104
+ - uses: actions/checkout@v4
105
+ - uses: actions/download-artifact@v4
106
+ with:
107
+ name: dist
108
+ path: dist/
109
+ - name: Extract version from tag
110
+ id: ver
111
+ run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
112
+ - name: Create Release + attach assets
113
+ uses: softprops/action-gh-release@v2
114
+ with:
115
+ name: "PatchCraft ${{ github.ref_name }}"
116
+ body: |
117
+ See [`CHANGELOG.md`](https://github.com/LeoPR/PatchCraft/blob/main/CHANGELOG.md) for the full notes.
118
+
119
+ Install:
120
+ ```
121
+ pip install patchcraft==${{ steps.ver.outputs.version }}
122
+ ```
123
+ files: |
124
+ dist/*.whl
125
+ dist/*.tar.gz
126
+ fail_on_unmatched_files: true
127
+ draft: false
128
+ prerelease: false
@@ -19,6 +19,12 @@ jobs:
19
19
  matrix:
20
20
  os: [ubuntu-latest, windows-latest]
21
21
  python: ["3.12", "3.13"]
22
+ env:
23
+ # Force uv to use the matrix Python in every uv* invocation
24
+ # (otherwise `uv run` re-resolves to its default, which is the
25
+ # newest installed interpreter -- masking the matrix and leaking
26
+ # the dev deps installed under a different version).
27
+ UV_PYTHON: ${{ matrix.python }}
22
28
  steps:
23
29
  - uses: actions/checkout@v4
24
30
 
@@ -28,10 +34,12 @@ jobs:
28
34
  enable-cache: true
29
35
 
30
36
  - name: Set up Python ${{ matrix.python }}
31
- run: uv python install ${{ matrix.python }}
37
+ run: uv python install
32
38
 
33
39
  - name: Install project with dev + cache extras
34
- run: uv sync --extra cache --extra dev --python ${{ matrix.python }}
40
+ # --locked: fail if uv.lock is stale or absent rather than silently
41
+ # re-resolving, so the committed lock is what CI actually validates.
42
+ run: uv sync --locked --extra cache --extra dev
35
43
 
36
44
  - name: Ruff
37
45
  run: uv run ruff check src tests
@@ -0,0 +1,340 @@
1
+ # Changelog
2
+
3
+ All notable changes to PatchCraft will be documented here. Format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+
11
+ - **`pair()`'s docstring double-applied the stride, and could silently
12
+ misplace every patch for a caller who trusted it.** It said patch `k` has
13
+ its top-left at LR pixel `(row * sh_lr, col * sw_lr)`, but `PatchMeta.row`
14
+ and `PatchMeta.col` already have the stride applied: they are built as
15
+ `(k // num_w_lr) * sh_lr` and `(k % num_w_lr) * sw_lr`. Following the
16
+ docstring multiplied by the stride a second time and landed on a different
17
+ patch. Only the text was wrong; the code, the tests and the runtime
18
+ behaviour were correct throughout, so no output of any released version
19
+ changes.
20
+
21
+ The failure mode was quiet, which is why it is worth a changelog entry: no
22
+ exception is raised, the patch exists, and only its recorded position is
23
+ wrong, so a consumer using patch position as a feature gets a worse model
24
+ rather than a crash. It is also invisible at `stride == 1`, where the grid
25
+ index and the pixel coordinate coincide, which is why it survived review.
26
+
27
+ Root cause was a name collision rather than a typo. `row`/`col` mean grid
28
+ indices in `THEORY.md` §1 (where `(row · sh, col · sw)` is the correct
29
+ pixel formula) and pixel coordinates in `PatchMeta`. The `pair()` docstring
30
+ took §1's formula and applied it to `PatchMeta`'s fields. `THEORY.md` §3
31
+ now flags the collision explicitly instead of carrying both meanings eight
32
+ lines apart, and `tests/test_pair.py` gains
33
+ `test_meta_coords_are_pixels_not_grid_indices`, which uses `stride != 1`
34
+ and asserts both that the stored coordinate indexes the right region and
35
+ that the discarded formula indexes a different one.
36
+
37
+ Reported from an API review done for the QSR prototype, which uses patch
38
+ position as a context feature.
39
+
40
+ ## [0.2.2] 2026-08-30
41
+
42
+ Documentation release. No behaviour changes, so every call that worked on 0.2.1
43
+ works identically here, and the only file under `src/` that changed is a
44
+ docstring.
45
+
46
+ ### Fixed
47
+
48
+ - **`reconstruct`'s docstring claimed the overlap round trip returns the
49
+ original image.** It said each pixel comes back as "the average of all patches
50
+ covering it, the same as the original when patches came from `extract`
51
+ unmodified". The second half is false. Measured on a 16x16 image with
52
+ `patch_size=4, stride=1`, unmodified patches straight from `extract` come back
53
+ with `torch.equal` False and a maximum absolute error of 2.4e-07 in float32.
54
+
55
+ The docstring now states the real rule: the round trip is bit-exact when every
56
+ value in the count map is a power of two, because dividing a float by a power
57
+ of two is the one division that never rounds. It also says that widening the
58
+ dtype does not rescue it, since the deciding axis is the geometry rather than
59
+ the precision.
60
+
61
+ ### Changed
62
+
63
+ - **The README is now a call page and the manual moved to `docs/GUIDE.md`.** The
64
+ README had reached 864 lines, which made it a manual printed on the front
65
+ door. It is 137 lines now, and it answers what the library is, where you are
66
+ getting into, and which of `reconstruct` or `stitch` you want. Nothing was
67
+ discarded: every measurement, table and long example moved into the guide,
68
+ which gained its own table of contents.
69
+ - **PyPI gets its own page.** `pyproject.toml` publishes `README.pypi.md`, which
70
+ stands alone for a reader arriving from a search, and carries only absolute
71
+ links because PyPI drops relative ones.
72
+ - **A Portuguese cover page joins the English one.** `README.pt-BR.md` is the
73
+ translation, `README.md` stays canonical, both carry an l10n colophon sharing
74
+ one `doc_id`, and git measures whether the translation is current.
75
+
76
+ ## [0.2.1] 2026-08-04
77
+
78
+ Bugfix release. Closes the full correctness backlog found by the 0.2.0 audit
79
+ (2026-08-03). Every fix below was first reproduced as a failing regression
80
+ test measuring the same quantities quoted from the audit, then fixed, then
81
+ verified by round-trip lab scripts under `lab/2026-08-04-*.py` (reports in
82
+ `Z:\outputs\patchcraft\2026-08-04-*\`). Test suite: 309 → 345 passed. No new
83
+ features; the only namespace change is `WeightKind` becoming reachable, which
84
+ was itself an audit defect.
85
+
86
+ ### Fixed: the four audit defects
87
+
88
+ - **`reconstruct` and `stitch` silently zeroed every pixel the patch grid did
89
+ not cover.** Both validated only the patch *count*
90
+ (`n_patches == num_h * num_w`) and never the *coverage*
91
+ (`(num_h - 1) * sh + ph == h`). A truncated grid returned a partly-black
92
+ image instead of raising, contradicting the bit-exact round-trip guarantee
93
+ in `docs/THEORY.md` §9.2. Measured on 0.2.0: `10×10` with `patch_size=4,
94
+ stride=4` returned 36 of 100 pixels zeroed; `13×13` with `patch_size=5,
95
+ stride=5` returned 69 of 169. Both functions now reject any grid whose last
96
+ patch does not end exactly on the image edge, with a `ValueError` naming the
97
+ covered vs. requested extents and pointing at `patchcraft.tilings`.
98
+ - **`stitch(..., weight="hann")` zeroed most of the image, not the four
99
+ corners.** The old `_hann_1d(n)` was the symmetric window
100
+ `0.5·(1 − cos(2πi/(n−1)))`, exactly zero at both endpoints. Measured on
101
+ 0.2.0: `12×12, patch 4, stride 4` came back with 108 of 144 pixels zeroed;
102
+ `patch_size=2` degenerated to an all-zero window and returned an all-black
103
+ image with no error. The window is now the interior of a longer symmetric
104
+ Hann window, `hann_window(n + 2, periodic=False)[1:-1]`, strictly positive
105
+ on every sample (THEORY §2.5). Output values of `stitch(hann)` change for
106
+ every geometry; round-trip of unmodified patches remains exact.
107
+ - **`resize` corrupted integer dtypes on the torch backend.** `_resize_torch`
108
+ cast back with a bare `out[0].to(original_dtype)`, with no clamp and no
109
+ round. Bicubic legitimately overshoots the input range, so the cast wrapped:
110
+ `-9.0 → 247`, `281.9 → 25`. Measured on 0.2.0: an 8×8 uint8 hard edge
111
+ resized to 32×32 had 256 of 1024 pixels wrong, black pixels becoming 254
112
+ and white pixels becoming 1. The cast back now rounds and clamps to the
113
+ `torch.iinfo` range of the integer dtype. Separately, the `pil` backend's
114
+ clamp of out-of-range floats to `[0, 1]` (the uint8 hop) is now documented
115
+ behavior in the docstring and THEORY §9.4 instead of a silent surprise.
116
+ - **`per_patch_mse` / `per_patch_psnr` did not promote to `float64`** the way
117
+ `patch_metrics` does. On 0.2.0, `uint8` input raised a raw torch
118
+ `RuntimeError` with no mention of PatchCraft, and `float16` input silently
119
+ returned `inf`. Both now compute in `float64` for any input dtype and always
120
+ return `float64` (contract change recorded in THEORY §9.8).
121
+
122
+ ### Fixed: secondary defects recorded by the audit
123
+
124
+ - **`reconstruct` and `stitch` overflowed to `inf` on `float16`/`bfloat16`.**
125
+ `F.fold` accumulates the sum of all overlapping patches before the count-map
126
+ division, and the numerator exceeds the fp16 finite max (65504) well before
127
+ the division happens. Measured on 0.2.0: fp16 constant image of `10000.0`,
128
+ `patch 3, stride 1`, returned `inf` in 144 of 256 pixels. Half-precision
129
+ inputs now accumulate internally in `float32` (fold, count map, division)
130
+ and are cast back to the original dtype on return.
131
+ - **`reconstruct` rejected integer dtypes with a raw torch
132
+ `NotImplementedError`** (`col2im_out_cpu` not implemented for
133
+ `Byte`/`Int`/`Long`). It now raises a clear `ValueError` up front, matching
134
+ the `stitch` guard.
135
+ - **`WeightKind` was unreachable from the public namespace** despite appearing
136
+ in `stitch`'s signature. It is now exported by `patchcraft.stitch` and
137
+ re-exported by `patchcraft` (public API: 18 → 19 symbols).
138
+
139
+ ### Fixed: CI and packaging (carried over from the audit pass)
140
+
141
+ - The sdist no longer ships `lab/` or `.vscode/`. The published
142
+ `patchcraft-0.2.0.tar.gz` contains `lab/usage_demo.py`,
143
+ `lab/usage_demo.out` and `lab/2026-05-16-roundtrip-mnist.py`, three files
144
+ that `lab/.gitignore` excludes from version control, which made that sdist
145
+ impossible to reproduce from any commit. The wheel was never affected.
146
+ - `.github/workflows/release.yml` passes `skip-existing: true` to
147
+ `gh-action-pypi-publish`, so re-running the pipeline on an already-published
148
+ version is a no-op instead of a hard failure that also skips the
149
+ GitHub Release job. The `validate` job now refuses to proceed when the tag
150
+ does not match `patchcraft.__version__`.
151
+ - Both workflows run `uv sync --locked`, so `uv.lock` is now actually enforced
152
+ in CI rather than being advisory.
153
+
154
+ ## [0.2.0] 2026-05-17
155
+
156
+ Second public release. Adds three feature groups motivated by the QPatchSR
157
+ super-resolution consumer plus internal ergonomics. No breaking changes vs
158
+ v0.1.0, so all v0.1.0 imports keep working.
159
+
160
+ ### Changed: package name (twice, both on 2026-05-17)
161
+
162
+ The project shipped v0.1.0 to GitHub under the name **PatchKit** and was
163
+ renamed twice in the run-up to the first PyPI upload, each time because the
164
+ name was already taken:
165
+
166
+ 1. `patchkit` → `patchforge` (commit `f761834`), because `pypi.org/project/patchkit/`
167
+ belongs to an unrelated model-patching utility.
168
+ 2. `patchforge` → `patchcraft` (commit `627a9c8`, final), because
169
+ `pypi.org/project/patchforge/` was also taken, by a llama-server CLI.
170
+
171
+ Both renames were done by string substitution across the tree, which rewrote
172
+ the historical entries below: the v0.1.0 section of this file says
173
+ `patchcraft`, but `git show v0.1.0:pyproject.toml` says `name = "patchkit"`
174
+ and `git ls-tree v0.1.0 src/` says `src/patchkit`. **v0.1.0 was never
175
+ published to PyPI under any name**: `patchcraft` 0.2.0 is the first and only
176
+ PyPI release, so no import path that ever existed on PyPI has changed, and no
177
+ migration shim is needed. Read the v0.1.0 section as a record of *what the API
178
+ was*, not of *what the package was called*.
179
+
180
+ ### Added: cross-resolution geometry (THEORY §1.5, §9.7)
181
+
182
+ Motivated by the QPatchSR consumer's question: "given two image shapes
183
+ (LR and HR of the same source), what `(patch_size, stride)` on each
184
+ side yields the same number of patches with corresponding regions?"
185
+ Three new helpers in `patchcraft.geometry`:
186
+
187
+ - **`scale_factor(lr_shape, hr_shape) -> int | None`** returns the
188
+ integer `k` such that `hr.shape[-2:] == (k * lr.shape[-2], k *
189
+ lr.shape[-1])`, or `None`. Accepts `(H, W)` or `(C, H, W)`. Pre-
190
+ flight check for `pair`.
191
+ - **`paired_tilings(lr_shape, hr_shape, *, allow_overlap=False, ...)`**
192
+ enumerates every `(lr_spec, hr_spec)` pair where both fully cover
193
+ their respective image and produce identical patch counts. Patch
194
+ `k` on each side covers the same image region.
195
+ Example: `paired_tilings((14, 14), (28, 28))` returns three pairs:
196
+ `(p_lr=2, p_hr=4, total=49)`, `(p_lr=7, p_hr=14, total=4)`,
197
+ `(p_lr=14, p_hr=28, total=1)`.
198
+ - **`PairedTilingSpec(lr, hr, scale_factor)`** is a `NamedTuple` carrying
199
+ both sides and the discovered scale factor.
200
+
201
+ ### Added: patch-level pixel metrics (THEORY §1.6, §9.8)
202
+
203
+ Canonical reductions so consumers don't reinvent slightly-divergent
204
+ versions in every project. New module `patchcraft.metrics`:
205
+
206
+ - **`patch_metrics(a, b, *, max_value=1.0) -> dict[str, float]`**:
207
+ scalar `mae`, `mse`, `max_abs`, `psnr_db` over the full tensor
208
+ (any matching shape works). Internal accumulation in `float64`
209
+ for stability; PSNR returns `+inf` for identical inputs.
210
+ - **`per_patch_mse(a, b) -> Tensor[L]`** gives one MSE per patch in a
211
+ `(L, C, h, w)` stack.
212
+ - **`per_patch_psnr(a, b, *, max_value=1.0) -> Tensor[L]`** gives one
213
+ PSNR per patch. Identical patches yield `+inf` via `torch.where`
214
+ (no clamp tricks).
215
+
216
+ Explicitly **not** included: SSIM, MS-SSIM, LPIPS, FID, any windowed
217
+ or learned metric. Use `pytorch-msssim`, `lpips`, `clean-fid` on the
218
+ caller side ([SCOPE.md](docs/SCOPE.md) §4.3 explains the boundary).
219
+
220
+ ### Added: patch stitching for modified patches (THEORY §2.5, §9.9)
221
+
222
+ `reconstruct` is the bit-exact inverse of `extract`. When patches have
223
+ been modified (model output, denoised, super-resolved), averaging them
224
+ back uniformly shows visible seams at patch boundaries. `stitch` is the
225
+ seam-aware counterpart: it folds patches weighted by a 2-D window
226
+ kernel so each pixel "trusts" patches closer to its center more.
227
+
228
+ - **`stitch(patches, image_shape, stride, *, weight="uniform"|"hann"|"gaussian", dilation=1)`**
229
+ keeps the same `F.fold` geometry and rejections as `reconstruct`; adds a
230
+ weighted-blend numerator over a weighted-sum denominator. With
231
+ `weight="uniform"` it is mathematically equivalent to `reconstruct`
232
+ (covered by a bit-exact equality test on no-overlap and `allclose`
233
+ on overlap). With `"hann"` it strongly suppresses seams at the
234
+ cost of zeroing image corners that fall on Hann's edge-weight-zero
235
+ region (documented artifact). With `"gaussian"`
236
+ (`sigma = max(1, min(ph, pw) / 4)`) it blends smoothly with no
237
+ corner artifact.
238
+
239
+ Floating-point patches only, because window kernels are float-valued and we
240
+ refuse to silently quantize or implicitly promote. Caller converts to
241
+ `float` first.
242
+
243
+ ### Changed
244
+
245
+ - Public API surface: 11 → 18 symbols.
246
+ - [`docs/SCOPE.md`](docs/SCOPE.md) gains rows for paired tilings,
247
+ pixel metrics, and stitch; §4.3 discusses why pixel metrics stayed
248
+ core while windowed/learned metrics did not, §4.4 explains why
249
+ `stitch` is a separate function from `reconstruct` rather than a
250
+ kwarg.
251
+ - [`docs/THEORY.md`](docs/THEORY.md) gains §1.5 expansion (cross-
252
+ resolution paragraphs), §1.6 (patch comparison metrics), §2.5
253
+ (stitch: math, kernels, why it is separate), §9.7
254
+ (paired tilings contract), §9.8 (metrics contract), §9.9 (stitch
255
+ contract).
256
+
257
+ ## [0.1.0] 2026-05-16
258
+
259
+ First public release. Public API stable; signatures will only change in 1.x.
260
+
261
+ ### Added: core (one image at a time)
262
+
263
+ - **`extract(image, patch_size, stride, dilation=1)`** gives patches from a
264
+ `(C, H, W)` tensor via `torch.nn.functional.unfold`. Truncation-only
265
+ boundary; returns `Tensor[0, C, ph, pw]` when geometry fits no patch.
266
+ Per [ADR 0001](docs/ADR/0001-patch-extraction-api.md).
267
+ - **`Patchify(patch_size, stride, dilation=1)`** is the callable wrapper for
268
+ `torchvision.transforms.Compose([...])`. Eager geometry validation in
269
+ `__init__`; `__slots__`-bound (no state beyond config). Per
270
+ [ADR 0002](docs/ADR/0002-patchify-transform.md).
271
+ - **`reconstruct(patches, image_shape, stride, dilation=1)`** is the inverse
272
+ of `extract` via `F.fold` plus a fold-of-ones count map. Bit-exact
273
+ round-trip for `stride == patch_size`; weighted-exact for overlap.
274
+ Rejects `dilation != 1` and `stride > patch_size` (partial coverage
275
+ is forbidden by design, because synthesizing pixel values is not PatchCraft's
276
+ job).
277
+ - **`pair(lr_image, hr_image, lr_patch_size, scale_factor, stride, *, image_id=None)`**
278
+ gives LR/HR patch correspondences. Returns a frozen `PatchPair`
279
+ dataclass with `lr_patches`, `hr_patches`, `metas`. Integer
280
+ `scale_factor` only. LR and HR must share `C`, dtype, and device.
281
+ - **`PatchPair`**, **`PatchMeta`** are frozen `@dataclass(slots=True)`.
282
+ `PatchMeta` carries `patch_index`, `row`, `col` (LR coords),
283
+ `lr_patch_size`, `hr_patch_size`, `image_id`. CPU-only metadata.
284
+ - **`resize(image, target_size, backend="pil", resample=None)`**:
285
+ single-image resize. Output type matches input
286
+ (PIL → PIL, Tensor → Tensor). Cross-backend conversions go through
287
+ a float32 [0, 1] / uint8 hop (numpy intermediate; no torchvision in
288
+ the core). CUDA tensors accepted only with `backend="torch"`.
289
+ - **`Cache(root, namespace, version=1)`** is a content-addressed disk
290
+ cache. `key_for(*parts) → str`, `put(key, bytes)`, `get(key) → bytes | None`.
291
+ Atomic write via `*.tmp` + `os.replace` with retry on transient
292
+ `PermissionError` (5 attempts on put with exponential backoff
293
+ `0.25/0.5/1/2/4` s, which handles OneDrive, antivirus, Windows Search
294
+ races). Optional zstandard compression at level 3 (`[cache]` extra);
295
+ uncompressed fallback when not installed. Sidecar JSON carries
296
+ SHA-256 checksum; corruption surfaces as `OSError`.
297
+ - **`num_patches(image_shape, patch_size, stride, dilation=1)`** is the
298
+ patch count formula, exposed as a function. No allocation, no
299
+ tensor. Accepts `(H, W)` or `(C, H, W)`.
300
+ - **`tilings(image_shape, *, allow_overlap=False, min_patch_size=2, max_patch_size=None)`**
301
+ enumerates every square, full-coverage `(patch_size, stride)`
302
+ geometry. Always emits `dilation=(1, 1)`. With default flags returns
303
+ exact tilings only (`patch_size == stride`, divisibility); with
304
+ `allow_overlap=True` adds clean-edge overlap geometries. Truncated
305
+ geometries are deliberately excluded, because the function answers "what is
306
+ sound by construction?", not "what will `extract` accept?".
307
+ - **`TilingSpec`** is a `NamedTuple(patch_size, stride, dilation,
308
+ num_patches, total_patches, overlap)`.
309
+
310
+ ### Added: packaging
311
+
312
+ - `py.typed` marker (PEP 561): downstream `mypy` now honors PatchCraft's
313
+ type hints.
314
+ - `[cache]` extra: pulls `zstandard>=0.22` for compressed cache
315
+ entries. Core works without it.
316
+
317
+ ### Out of scope (v0.1.x)
318
+
319
+ - Multi-image batched API: use a `for` loop, `torch.vmap`, or a
320
+ `DataLoader`. See `Patchify` for `transforms.Compose` integration.
321
+ - Dataset orchestration (download, batching, sampling): the auxiliary
322
+ framework in [`tests/_datasets.py`](tests/_datasets.py) handles this
323
+ for the test suite and `lab/` scripts; it is not shipped in the wheel.
324
+ - Channels-last layout, quantization, `nn.Module` integration:
325
+ documented in [`docs/THEORY.md`](docs/THEORY.md) §6 and §8 (open
326
+ questions).
327
+
328
+ ### Documentation
329
+
330
+ - [`docs/THEORY.md`](docs/THEORY.md) has §0 binding scope, §§1–6 design
331
+ decisions per primitive, §7 resolved questions, §8 open questions,
332
+ §9 the per-API condition contract (Accepts / Rejects / Out of scope)
333
+ that the test suite mirrors.
334
+ - [`docs/ADR/0001-patch-extraction-api.md`](docs/ADR/0001-patch-extraction-api.md)
335
+ and [`docs/ADR/0002-patchify-transform.md`](docs/ADR/0002-patchify-transform.md).
336
+ - [`README.md`](README.md) covers installation, the car-vs-track metaphor,
337
+ validation lab.
338
+
339
+ [0.2.0]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.2.0
340
+ [0.1.0]: https://github.com/LeoPR/PatchCraft/releases/tag/v0.1.0