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.
- {patchcraft-0.2.0 → patchcraft-0.2.2}/.github/workflows/release.yml +128 -111
- {patchcraft-0.2.0 → patchcraft-0.2.2}/.github/workflows/test.yml +10 -2
- patchcraft-0.2.2/CHANGELOG.md +340 -0
- patchcraft-0.2.2/CONTRIBUTING.md +98 -0
- patchcraft-0.2.2/PKG-INFO +155 -0
- patchcraft-0.2.2/README.md +138 -0
- patchcraft-0.2.2/README.pt-BR.md +138 -0
- patchcraft-0.2.2/README.pypi.md +115 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ADR/0001-patch-extraction-api.md +100 -100
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ADR/0002-patchify-transform.md +101 -101
- patchcraft-0.2.2/docs/ADR/0003-reversibility-classes.md +107 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/AUXILIARY.md +169 -167
- patchcraft-0.2.2/docs/FOCO-1.0.md +110 -0
- patchcraft-0.2.2/docs/GUIDE.md +848 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/ROADMAP.md +111 -94
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/SCOPE.md +293 -282
- patchcraft-0.2.2/docs/STUDIES/2026-08-04-patch-techniques.md +190 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/THEORY.md +460 -451
- {patchcraft-0.2.0 → patchcraft-0.2.2}/docs/USAGE.md +491 -482
- {patchcraft-0.2.0 → patchcraft-0.2.2}/pyproject.toml +100 -89
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/__init__.py +40 -39
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/cache.py +3 -3
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/extract.py +1 -1
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/geometry.py +290 -290
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/metrics.py +159 -151
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/pair.py +166 -161
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/reconstruct.py +155 -118
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/resize.py +14 -1
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/stitch.py +239 -215
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/_datasets.py +117 -117
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/conftest.py +1 -1
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_cache.py +254 -254
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_extract.py +250 -250
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_geometry.py +333 -333
- patchcraft-0.2.2/tests/test_import.py +14 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_metrics.py +213 -170
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_pair.py +241 -212
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_reconstruct.py +262 -205
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_resize.py +229 -187
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/test_stitch.py +283 -229
- patchcraft-0.2.2/uv.lock +1041 -0
- patchcraft-0.2.0/.vscode/settings.json +0 -3
- patchcraft-0.2.0/CHANGELOG.md +0 -175
- patchcraft-0.2.0/PKG-INFO +0 -266
- patchcraft-0.2.0/README.md +0 -226
- patchcraft-0.2.0/lab/.gitignore +0 -3
- patchcraft-0.2.0/lab/2026-05-16-roundtrip-mnist.py +0 -80
- patchcraft-0.2.0/lab/README.md +0 -34
- patchcraft-0.2.0/lab/usage_demo.out +0 -174
- patchcraft-0.2.0/lab/usage_demo.py +0 -239
- patchcraft-0.2.0/tests/test_import.py +0 -7
- patchcraft-0.2.0/uv.lock +0 -1010
- {patchcraft-0.2.0 → patchcraft-0.2.2}/.gitignore +0 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/.python-version +0 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/LICENSE +0 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/src/patchcraft/py.typed +0 -0
- {patchcraft-0.2.0 → patchcraft-0.2.2}/tests/__init__.py +0 -0
- {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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
- run: uv
|
|
36
|
-
- run: uv
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
- run: uv
|
|
47
|
-
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
37
|
+
run: uv python install
|
|
32
38
|
|
|
33
39
|
- name: Install project with dev + cache extras
|
|
34
|
-
|
|
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
|