PyPatchMatch 2.0.0rc5__tar.gz → 2.0.0rc6__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 (28) hide show
  1. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/CLAUDE.md +63 -20
  2. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/PKG-INFO +12 -11
  3. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/README.md +7 -6
  4. pypatchmatch-2.0.0rc6/SECURITY.md +20 -0
  5. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/_version.py +3 -3
  6. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/inpaint.cpp +12 -1
  7. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/pyproject.toml +13 -6
  8. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/scripts/build_opencv.py +15 -6
  9. pypatchmatch-2.0.0rc6/tests/conftest.py +6 -0
  10. pypatchmatch-2.0.0rc6/tests/test_properties.py +159 -0
  11. pypatchmatch-2.0.0rc5/tests/test_properties.py +0 -95
  12. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/CMakeLists.txt +0 -0
  13. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/LICENSE +0 -0
  14. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/examples/images/forest_pruned.bmp +0 -0
  15. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/__init__.py +0 -0
  16. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/_lib.py +0 -0
  17. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/inpaint.h +0 -0
  18. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/masked_image.cpp +0 -0
  19. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/masked_image.h +0 -0
  20. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/nnf.cpp +0 -0
  21. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/nnf.h +0 -0
  22. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/pyinterface.cpp +0 -0
  23. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/csrc/pyinterface.h +0 -0
  24. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/patch_match.py +0 -0
  25. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/patchmatch/py.typed +0 -0
  26. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/scripts/evaluate_inpainting.py +0 -0
  27. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/tests/test_e2e.py +0 -0
  28. {pypatchmatch-2.0.0rc5 → pypatchmatch-2.0.0rc6}/tests/test_patch_match.py +0 -0
@@ -190,7 +190,7 @@ The C++ headers double as the C++ API: `examples/cpp_example.cpp` uses `Inpainti
190
190
 
191
191
  The library does not use the CPython ABI, so the wheels are tagged
192
192
  `py3-none-<platform>` (`wheel.py-api = "py3"`): one wheel per platform serves all Python
193
- versions, and cibuildwheel only builds `cp310-*`. Keep `csrc` free of `Python.h`,
193
+ versions, and cibuildwheel only builds `cp311-*`. Keep `csrc` free of `Python.h`,
194
194
  pybind11 or nanobind. The C++ sources are excluded from the wheel (`wheel.exclude`);
195
195
  CMake installs the shared library into the package directory.
196
196
 
@@ -284,9 +284,11 @@ ctypes releases the GIL during native calls, so inpaintings run truly concurrent
284
284
  as maximal. The distance indexes `distance2similarity`, so every metric has to clamp
285
285
  its result to that range.
286
286
  - Mask semantics: `mask` non-zero marks the holes (by default all pure white pixels,
287
- computed in Python by `_default_mask`); `global_mask` marks pixels that are neither
288
- filled nor used as a patch source, and whose colors must not influence the fill: the
289
- downsampling kernel skips them, and gradients next to them are neutral
287
+ computed in Python by `_default_mask`); `_initialize_pyramid` makes them black on
288
+ level 0 as on the coarser levels, so their colors do not matter
289
+ (`test_colors_under_the_holes_do_not_matter`); `global_mask` marks pixels that are
290
+ neither filled nor used as a patch source, and whose colors must not influence the
291
+ fill: the downsampling kernel skips them, and gradients next to them are neutral
290
292
  (`test_colors_under_global_mask_do_not_matter`). At the end of `run()` every pixel
291
293
  that is not a hole, including the globally masked ones, is copied back from the input,
292
294
  so only the holes change. The tests assert this invariant (`assert_filled`,
@@ -314,8 +316,11 @@ The release wheels link a static, core-only OpenCV (`scripts/build_opencv.py`:
314
316
  is linked into the library. The wheel job checks that every wheel has the SBOM and,
315
317
  except on Windows, that `nm` finds no zlib symbol in the library. `write_sbom` fails
316
318
  if the build bundles another 3rdparty library; add a new one to the SBOM.
317
- - To update OpenCV, change `OPENCV_VERSION` and `OPENCV_SHA256` together. The CI cache of
318
- the OpenCV build is keyed on the hash of the script.
319
+ - To update OpenCV, change the default of `OPENCV_VERSION` and add its hash to
320
+ `OPENCV_SHA256S`. `PATCHMATCH_OPENCV_VERSION` selects another pinned version: the
321
+ CI job `opencv5` builds against OpenCV 5 (Homebrew's `opencv` since 2026), because
322
+ builds from the sdist link whatever OpenCV the system has. The CI cache of the
323
+ OpenCV build is keyed on the hash of the script.
319
324
 
320
325
  ### Packaging and versioning
321
326
 
@@ -338,8 +343,10 @@ The release wheels link a static, core-only OpenCV (`scripts/build_opencv.py`:
338
343
  ## CI
339
344
 
340
345
  - `ci.yml`: `lint` runs `pre-commit run --all-files`; `test` runs `uv run pytest` on
341
- Ubuntu, macOS and Windows with Python 3.10 and 3.14, after building (or restoring) the
342
- OpenCV of the build script.
346
+ Ubuntu, macOS and Windows with Python 3.11, 3.14 and 3.15, after building (or
347
+ restoring) the OpenCV of the build script. It syncs only the `test` group: the `dev`
348
+ group pulls in pyyaml through pre-commit, which had no wheel for 3.15 yet, and
349
+ `--no-build` refuses to build it.
343
350
  - pre-commit runs, besides the file checks, ruff and uv-lock: clang-format
344
351
  (`.clang-format`), shellcheck, markdownlint-cli2 (`.markdownlint.yaml`, 88 columns),
345
352
  typos (fixes in place), actionlint (checks `run:` scripts with shellcheck only if it
@@ -356,6 +363,10 @@ The release wheels link a static, core-only OpenCV (`scripts/build_opencv.py`:
356
363
  - `sanitizers` builds with `PATCHMATCH_SANITIZE=ON` on Ubuntu and runs the tests with
357
364
  the preloaded ASan runtime (see Commands). It reuses the checkout and OpenCV steps of
358
365
  `test` through YAML anchors, so change them there.
366
+ Scheduled (nightly) and manual runs of `ci.yml` pass `--hypothesis-profile=deep`
367
+ (`tests/conftest.py`: 5000 examples per property test, about 90 s locally without
368
+ sanitizers) and allow the job 90 minutes; a failure prints a `@reproduce_failure`
369
+ blob. The property tests found real bugs, so they get the most search time.
359
370
  - `coverage` builds with `PATCHMATCH_COVERAGE=ON` on Ubuntu, runs the tests and fails
360
371
  below 95 % of the C++ lines or 90 % of the branches (gcovr, see Commands).
361
372
  It reuses the same anchors. It also writes `coverage.xml` (Python) and
@@ -364,21 +375,43 @@ The release wheels link a static, core-only OpenCV (`scripts/build_opencv.py`:
364
375
  without the secret, e.g. for Dependabot and forks). Automatic analysis in SonarCloud
365
376
  is disabled; it cannot import coverage and would conflict with the CI scan. `scripts/`
366
377
  has no tests and is excluded from the coverage on new code (80 % in "Sonar way").
378
+ `sonar.qualitygate.wait=true` makes the scan fail on a red quality gate, so the gate
379
+ is part of `ci-ok` and auto-merge waits for it; the skipped scan of Dependabot and
380
+ fork PRs blocks nothing, unlike a required "SonarCloud Code Analysis" check, which
381
+ would never appear there.
367
382
  - The `uv run` steps pass `--locked` and `--no-build` (SonarCloud S8544, S8541).
368
383
  `--no-build` only forbids building dependencies from source; uv still builds the
369
- project itself. The OpenCV step runs with `--only-group dev`: the project cannot be
370
- built before OpenCV is installed, and `--no-project` would lock nothing.
384
+ project itself. The OpenCV step runs with `--only-group test`: the project cannot be
385
+ built before OpenCV is installed, `--no-project` would lock nothing, and the `dev`
386
+ group may lack wheels for the newest Python of the matrix.
387
+ - Every setup-uv step sets `version: latest-known`: the newest uv whose checksum is
388
+ bundled with the pinned setup-uv. Without it, CI installs each uv release minutes
389
+ after publication; this way uv only moves when Dependabot updates the action, with
390
+ its cooldown. That uv can be a few releases older than the one of the `uv-lock` hook.
391
+ `prune-cache: true` keeps only wheels built from source in the uv cache; downloaded
392
+ wheels restore no faster than they download.
371
393
  - `system-opencv` installs `libopencv-dev` on `ubuntu-24.04` (OpenCV 4.6) instead of
372
394
  running the build script and runs the tests, because builds from the sdist link
373
395
  whatever OpenCV the system has. The runner is pinned so that the OpenCV version only
374
- changes on purpose.
396
+ changes on purpose. The Ubuntu mirror is sometimes very slow (180 MB at under
397
+ 300 kB/s), so the job caches the downloaded `.deb` files, keyed on the hash of
398
+ `apt-get install --print-uris`, with `restore-keys` for the files that did not change.
399
+ apt checks them against the signed index and dpkg installs them as usual. The job
400
+ downloads first (`--download-only`) and caches only the files of the current list.
375
401
  - `wheels.yml`: sdist, then cibuildwheel on five runners, then `sdist-fallback`;
376
402
  `publish` attests the build provenance of all files (`actions/attest`) and uploads
377
- them to PyPI with trusted publishing (environment `pypi`) only for a published GitHub
378
- release of `mauwii/PyPatchMatch`. The wheel jobs cache the OpenCV prefix, keyed on
379
- `build_opencv.py` and `pyproject.toml` from the sdist (there is no checkout); on Linux
380
- a volume makes `/tmp/opencv` of the container visible to the cache step. Runs on a
381
- tag, and therefore releases, skip the cache and build OpenCV from scratch.
403
+ them to PyPI with trusted publishing (environment `pypi`, deployable only from tags
404
+ `v*`) only for a published GitHub release of `mauwii/PyPatchMatch`. It downloads the
405
+ artifacts named `dist-*`, so other artifacts of the run never reach PyPI. The wheel
406
+ jobs cache the OpenCV prefix, keyed on `build_opencv.py` and `pyproject.toml` from the
407
+ sdist (there is no checkout); on Linux a volume makes `/tmp/opencv` of the container
408
+ visible to the cache step. Runs on a tag, and therefore releases, skip the cache and
409
+ build OpenCV from scratch.
410
+ The build and test dependencies of the distributions are not locked, so the workflow
411
+ sets `UV_EXCLUDE_NEWER: 7 days` (the cooldown of `dependabot.yml`): `uv build` and
412
+ cibuildwheel's `build-frontend = "build[uv]"` resolve only releases older than a
413
+ week. On Linux `environment-pass` hands the variable into the container; on macOS
414
+ and Windows cibuildwheel uses the uv of a setup-uv step.
382
415
  - `fill-quality.yml` runs for pull requests that change `CMakeLists.txt`,
383
416
  `patchmatch/csrc` or the evaluation script: it builds the library of the base commit,
384
417
  runs `scripts/evaluate_inpainting.py --baseline` against it (patch size 3, 3 seeds),
@@ -387,15 +420,25 @@ The release wheels link a static, core-only OpenCV (`scripts/build_opencv.py`:
387
420
  so it is not in the `needs` of `ci-ok`. It repeats the OpenCV steps of `ci.yml` with
388
421
  the same cache key, because anchors do not reach across files.
389
422
  - `codeql.yml` analyzes actions, C/C++ and Python.
423
+ - `cache-cleanup.yml` deletes the caches of a pull request when it is closed: only that
424
+ pull request can read them, and at the 10 GB limit GitHub evicts the least recently
425
+ used caches, often those of `main`. For pull requests from forks the token is
426
+ read-only; their caches expire after 7 days without access.
390
427
  - `ci-ok` and `wheels-ok` (re-actors/alls-green) are the required status checks; add new
391
- jobs to their `needs`.
428
+ jobs to their `needs`. The ruleset of `main` also has a `code_scanning` rule: CodeQL
429
+ alerts of severity error or security severity high and above block the merge (CodeQL
430
+ uploads its results for Dependabot PRs too, so they are not stuck).
392
431
  - Actions are pinned to full commit SHAs with a `# vX.Y.Z` comment, and checkouts use
393
- `persist-credentials: false`. Dependabot updates actions, uv and pre-commit weekly in
394
- groups.
432
+ `persist-credentials: false`. The pre-commit hooks are pinned the same way
433
+ (`rev: <sha> # frozen: vX.Y.Z`; `pre-commit autoupdate --freeze` by hand), because a
434
+ moved tag would otherwise reach CI and fresh clones without review. Dependabot
435
+ updates actions, uv and pre-commit weekly in groups, SHA and comment together. The
436
+ repository is a GitHub fork, on which Dependabot version updates are off by default;
437
+ they are enabled in Insights → Dependency graph → Dependabot.
395
438
 
396
439
  ## Conventions
397
440
 
398
- - Python >= 3.10: keep `from __future__ import annotations`. Ruff targets py310 with the
441
+ - Python >= 3.11: keep `from __future__ import annotations`. Ruff targets py311 with the
399
442
  rules B, C4, E, F, I, RUF, SIM, UP and W, and formats at 88 columns. `patchmatch/csrc`
400
443
  is excluded from ruff. C++ is formatted by clang-format (Microsoft style, 120 columns,
401
444
  includes grouped as own header, `<...>`, `"..."`); naming is not checked: `m_`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.0.0rc5
3
+ Version: 2.0.0rc6
4
4
  Summary: This library implements the PatchMatch based inpainting algorithm.
5
5
  Keywords: inpainting,patchmatch,image-processing,opencv
6
6
  Author-Email: The InvokeAI Project <lincoln.stein@gmail.com>, Jiayuan Mao <maojiayuan@gmail.com>, Kyle Schouviller <kyle0654@hotmail.com>, Matthias Wild <mauwii@outlook.de>, Younesse ANDAM <younesse.andam@gmail.com>
@@ -12,20 +12,20 @@ Classifier: Operating System :: Microsoft :: Windows
12
12
  Classifier: Operating System :: POSIX :: Linux
13
13
  Classifier: Programming Language :: C++
14
14
  Classifier: Programming Language :: Python :: 3
15
- Classifier: Programming Language :: Python :: 3.10
16
15
  Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
20
  Classifier: Topic :: Multimedia :: Graphics
21
21
  Classifier: Topic :: Scientific/Engineering :: Image Processing
22
22
  Classifier: Typing :: Typed
23
23
  Project-URL: Source Code, https://github.com/mauwii/PyPatchMatch
24
24
  Project-URL: Issues, https://github.com/mauwii/PyPatchMatch/issues
25
25
  Project-URL: Release Notes, https://github.com/mauwii/PyPatchMatch/releases
26
- Requires-Python: >=3.10
27
- Requires-Dist: numpy>=1.21.6
28
- Requires-Dist: pillow>=8.4.0
26
+ Requires-Python: >=3.11
27
+ Requires-Dist: numpy>=1.23.2
28
+ Requires-Dist: pillow>=9.2.0
29
29
  Description-Content-Type: text/markdown
30
30
 
31
31
  # PatchMatch based Inpainting
@@ -78,12 +78,13 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
78
78
 
79
79
  The mask must have the same height and width as the image. If `mask` is omitted, all
80
80
  pure white pixels are treated as holes. Only the holes are filled; all other pixels keep
81
- their values. Holes need hard edges: an anti-aliased brush leaves light pixels along
82
- the edge of its strokes that are not pure white, so they count as known and the fill
83
- continues them. Paint without anti-aliasing or pass a mask that covers those pixels as
84
- well. The optional keyword argument `global_mask`, in the same format, marks
85
- pixels that are neither filled nor used as a source; they keep their values as well, and
86
- their colors have no influence on the fill (see
81
+ their values, and the colors under the holes have no influence on the fill. Holes need
82
+ hard edges: an anti-aliased brush leaves light pixels along the edge of its strokes that
83
+ are not pure white, so they count as known and the fill continues them. Paint without
84
+ anti-aliasing or pass a mask that covers those pixels as well. The optional keyword
85
+ argument `global_mask`, in the same format, marks pixels that are neither filled nor
86
+ used as a source; they keep their values as well, and their colors have no influence on
87
+ the fill (see
87
88
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
88
89
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
89
90
  The previous import path `from patchmatch import patch_match` keeps working.
@@ -48,12 +48,13 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
48
48
 
49
49
  The mask must have the same height and width as the image. If `mask` is omitted, all
50
50
  pure white pixels are treated as holes. Only the holes are filled; all other pixels keep
51
- their values. Holes need hard edges: an anti-aliased brush leaves light pixels along
52
- the edge of its strokes that are not pure white, so they count as known and the fill
53
- continues them. Paint without anti-aliasing or pass a mask that covers those pixels as
54
- well. The optional keyword argument `global_mask`, in the same format, marks
55
- pixels that are neither filled nor used as a source; they keep their values as well, and
56
- their colors have no influence on the fill (see
51
+ their values, and the colors under the holes have no influence on the fill. Holes need
52
+ hard edges: an anti-aliased brush leaves light pixels along the edge of its strokes that
53
+ are not pure white, so they count as known and the fill continues them. Paint without
54
+ anti-aliasing or pass a mask that covers those pixels as well. The optional keyword
55
+ argument `global_mask`, in the same format, marks pixels that are neither filled nor
56
+ used as a source; they keep their values as well, and their colors have no influence on
57
+ the fill (see
57
58
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
58
59
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
59
60
  The previous import path `from patchmatch import patch_match` keeps working.
@@ -0,0 +1,20 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ Security fixes are released as a new version of PyPatchMatch on PyPI. Only the latest
6
+ release is supported; older versions are not updated.
7
+
8
+ ## Reporting a Vulnerability
9
+
10
+ Please report vulnerabilities privately through GitHub's
11
+ [vulnerability reporting](https://github.com/mauwii/PyPatchMatch/security/advisories/new),
12
+ not in a public issue.
13
+
14
+ ## OpenCV
15
+
16
+ The wheels on PyPI contain a statically linked, core-only build of OpenCV; its version
17
+ is listed in the CycloneDX SBOM in `sboms/opencv.cdx.json` of each wheel's
18
+ `.dist-info`. A vulnerability in OpenCV core is fixed by a new PyPatchMatch release
19
+ with an updated OpenCV. Installations built from the source distribution link the
20
+ OpenCV of the system and get its fixes from there.
@@ -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 = '2.0.0rc5'
22
- __version_tuple__ = version_tuple = (2, 0, 0, 'rc5')
21
+ __version__ = version = '2.0.0rc6'
22
+ __version_tuple__ = version_tuple = (2, 0, 0, 'rc6')
23
23
 
24
- __commit_id__ = commit_id = 'g3dcd7aa74'
24
+ __commit_id__ = commit_id = 'g7cfa5cb5b'
@@ -164,7 +164,18 @@ Inpainting::Inpainting(
164
164
 
165
165
  void Inpainting::_initialize_pyramid()
166
166
  {
167
- auto source = m_initial;
167
+ // The colors under the holes would reach the fill through the gradients of their
168
+ // neighbors and the target of a single-level pyramid. Black, like on coarser levels.
169
+ auto source = m_initial.clone();
170
+ const auto size = source.size();
171
+ for (int y = 0; y < size.height; ++y)
172
+ {
173
+ for (int x = 0; x < size.width; ++x)
174
+ {
175
+ if (source.is_masked(y, x))
176
+ std::fill_n(source.get_mutable_image(y, x), 3, 0);
177
+ }
178
+ }
168
179
  m_pyramid.push_back(source);
169
180
  while (source.size().height > m_distance_metric->patch_size() &&
170
181
  source.size().width > m_distance_metric->patch_size())
@@ -15,7 +15,7 @@ authors = [
15
15
  { name = "Matthias Wild", email = "mauwii@outlook.de" },
16
16
  { name = "Younesse ANDAM", email = "younesse.andam@gmail.com" },
17
17
  ]
18
- requires-python = ">=3.10"
18
+ requires-python = ">=3.11"
19
19
  keywords = ["inpainting", "patchmatch", "image-processing", "opencv"]
20
20
  classifiers = [
21
21
  "Development Status :: 5 - Production/Stable",
@@ -24,16 +24,16 @@ classifiers = [
24
24
  "Operating System :: POSIX :: Linux",
25
25
  "Programming Language :: C++",
26
26
  "Programming Language :: Python :: 3",
27
- "Programming Language :: Python :: 3.10",
28
27
  "Programming Language :: Python :: 3.11",
29
28
  "Programming Language :: Python :: 3.12",
30
29
  "Programming Language :: Python :: 3.13",
31
30
  "Programming Language :: Python :: 3.14",
31
+ "Programming Language :: Python :: 3.15",
32
32
  "Topic :: Multimedia :: Graphics",
33
33
  "Topic :: Scientific/Engineering :: Image Processing",
34
34
  "Typing :: Typed",
35
35
  ]
36
- dependencies = ["numpy>=1.21.6", "pillow>=8.4.0"]
36
+ dependencies = ["numpy>=1.23.2", "pillow>=9.2.0"]
37
37
  dynamic = ["version"]
38
38
 
39
39
  [project.urls]
@@ -78,10 +78,13 @@ fallback_version = "0.0.0"
78
78
  [tool.cibuildwheel]
79
79
  # The wheels are Python-version independent (py3-none-<platform>), so each platform
80
80
  # is built and tested once; the CI workflow covers the other Python versions.
81
- build = "cp310-*"
81
+ build = "cp311-*"
82
82
  skip = "*-musllinux_*"
83
83
  # OpenCV is built once per job in before-all, only for the runner's native arch
84
84
  archs = ["native"]
85
+ # uv installs the build and test environments, so that UV_EXCLUDE_NEWER of wheels.yml
86
+ # applies to them
87
+ build-frontend = "build[uv]"
85
88
  test-groups = ["test"]
86
89
  test-command = "pytest {project}/tests --no-cov"
87
90
  before-all = "python {project}/scripts/build_opencv.py"
@@ -89,6 +92,7 @@ environment = { OpenCV_ROOT = "/tmp/opencv" }
89
92
 
90
93
  [tool.cibuildwheel.linux]
91
94
  before-all = "/opt/python/cp312-cp312/bin/python {project}/scripts/build_opencv.py"
95
+ environment-pass = ["UV_EXCLUDE_NEWER"]
92
96
 
93
97
  [tool.cibuildwheel.macos]
94
98
  environment = { OpenCV_ROOT = "/tmp/opencv", MACOSX_DEPLOYMENT_TARGET = "11.0" }
@@ -108,7 +112,7 @@ cache-keys = [
108
112
  [tool.ruff]
109
113
  extend-exclude = ["patchmatch/csrc"]
110
114
  line-length = 88
111
- target-version = "py310"
115
+ target-version = "py311"
112
116
 
113
117
  [tool.ruff.lint]
114
118
  select = ["B", "C4", "E", "F", "I", "RUF", "SIM", "UP", "W"]
@@ -138,7 +142,10 @@ warn_return_any = false
138
142
 
139
143
  [tool.pytest.ini_options]
140
144
  addopts = ["--cov-branch", "--cov-report=term:skip-covered", "--cov=patchmatch"]
145
+ # shows which test hangs before the job timeout kills the run
146
+ faulthandler_timeout = 300
141
147
  markers = ["e2e: end-to-end tests of the user workflows (deselect with -m 'not e2e')"]
142
- minversion = "6.0"
148
+ minversion = "9.0"
143
149
  python_files = ["test_*.py"]
150
+ strict = true
144
151
  testpaths = ["tests"]
@@ -4,6 +4,9 @@ The patchmatch library only needs opencv_core. Linking it statically keeps the
4
4
  wheels small and free of OpenCV's GUI/codec dependencies.
5
5
 
6
6
  Usage: python build_opencv.py (installs into $OpenCV_ROOT)
7
+
8
+ PATCHMATCH_OPENCV_VERSION selects another pinned version, which CI tests because
9
+ builds from the sdist link whatever OpenCV the system has.
7
10
  """
8
11
 
9
12
  import hashlib
@@ -17,13 +20,20 @@ import tempfile
17
20
  import urllib.request
18
21
  from pathlib import Path
19
22
 
20
- OPENCV_VERSION = "4.14.0"
23
+ # The wheels link this code statically, so every archive is pinned by its SHA-256,
24
+ # e.g. from `sha256sum` or `shasum -a 256`.
25
+ OPENCV_SHA256S = {
26
+ "4.14.0": "ee8fb9b30eb60850431b4656447080e3737b56e45719c92b67f245950609f86e",
27
+ "5.0.0": "b0528f5a1d379d59d4701cb28c36e22214cc51cf64594e5b56f2d3e6c0233095",
28
+ }
29
+ # the version of the release wheels
30
+ OPENCV_VERSION = os.environ.get("PATCHMATCH_OPENCV_VERSION") or "4.14.0"
31
+ if OPENCV_VERSION not in OPENCV_SHA256S:
32
+ sys.exit(f"OpenCV {OPENCV_VERSION} is not pinned, known: {sorted(OPENCV_SHA256S)}")
33
+ OPENCV_SHA256 = OPENCV_SHA256S[OPENCV_VERSION]
21
34
  OPENCV_URL = (
22
35
  f"https://github.com/opencv/opencv/archive/refs/tags/{OPENCV_VERSION}.tar.gz"
23
36
  )
24
- # The wheels link this code statically, so the archive is pinned by its SHA-256.
25
- # Update it together with the version, e.g. with `sha256sum` or `shasum -a 256`.
26
- OPENCV_SHA256 = "ee8fb9b30eb60850431b4656447080e3737b56e45719c92b67f245950609f86e"
27
37
 
28
38
  CMAKE_OPTIONS = {
29
39
  "CMAKE_BUILD_TYPE": "Release",
@@ -155,8 +165,7 @@ def main() -> None:
155
165
  urllib.request.urlretrieve(OPENCV_URL, archive)
156
166
  verify_archive(archive)
157
167
  with tarfile.open(archive) as tar:
158
- # extraction filters are missing on older patch releases, e.g. the
159
- # last Windows installer of Python 3.10 (3.10.11)
168
+ # extraction filters are missing on older patch releases (3.11 < 3.11.4)
160
169
  if hasattr(tarfile, "data_filter"):
161
170
  tar.extractall(tmp, filter="data")
162
171
  else:
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from hypothesis import settings
4
+
5
+ # weekly CI; print_blob shows how to reproduce a failure locally
6
+ settings.register_profile("deep", max_examples=5000, print_blob=True)
@@ -0,0 +1,159 @@
1
+ """Property-based tests: the invariants of the inpainting on random inputs."""
2
+
3
+ import numpy as np
4
+ from hypothesis import given, settings
5
+ from hypothesis import strategies as st
6
+ from hypothesis.extra import numpy as hnp
7
+
8
+ import patchmatch
9
+
10
+ sizes = st.integers(1, 24)
11
+ patch_sizes = st.integers(1, 4)
12
+
13
+
14
+ @st.composite
15
+ def images_and_masks(draw):
16
+ """An image, a hole mask (None: the white pixels) and an optional global mask."""
17
+ shape = (draw(sizes), draw(sizes))
18
+ image = draw(hnp.arrays(np.uint8, (*shape, 3)))
19
+ mask = draw(st.none() | hnp.arrays(np.bool_, shape))
20
+ global_mask = draw(st.none() | hnp.arrays(np.bool_, shape))
21
+ return image, mask, global_mask
22
+
23
+
24
+ def white_pixels(image):
25
+ return (image == 255).all(axis=-1)
26
+
27
+
28
+ def assert_only_holes_changed(result, image, mask, global_mask):
29
+ assert result.shape == image.shape
30
+ assert result.dtype == np.uint8
31
+ holes = white_pixels(image) if mask is None else mask
32
+ if global_mask is not None:
33
+ holes = holes & ~global_mask
34
+ np.testing.assert_array_equal(result[~holes], image[~holes])
35
+
36
+
37
+ def fills(image, mask, ijmap, **kwargs):
38
+ """The results of inpaint and inpaint_regularity with the same seed."""
39
+ patchmatch.set_random_seed(0)
40
+ return (
41
+ patchmatch.inpaint(image, mask, **kwargs),
42
+ patchmatch.inpaint_regularity(image, mask, ijmap, **kwargs),
43
+ )
44
+
45
+
46
+ def assert_same_fills(actual, expected):
47
+ for result, reference in zip(actual, expected, strict=True):
48
+ np.testing.assert_array_equal(result, reference)
49
+
50
+
51
+ @settings(deadline=None)
52
+ @given(images_and_masks(), patch_sizes)
53
+ def test_inpaint(inputs, patch_size):
54
+ image, mask, global_mask = inputs
55
+
56
+ def inpaint():
57
+ patchmatch.set_random_seed(0)
58
+ return patchmatch.inpaint(
59
+ image, mask, global_mask=global_mask, patch_size=patch_size
60
+ )
61
+
62
+ result = inpaint()
63
+ assert_only_holes_changed(result, image, mask, global_mask)
64
+ np.testing.assert_array_equal(inpaint(), result)
65
+
66
+
67
+ @settings(deadline=None)
68
+ @given(
69
+ images_and_masks(),
70
+ patch_sizes,
71
+ hnp.arrays(
72
+ np.float32,
73
+ st.tuples(sizes, sizes, st.just(3)),
74
+ elements=st.floats(-2, 2, width=32),
75
+ ),
76
+ st.floats(0, 10),
77
+ )
78
+ def test_inpaint_regularity(inputs, patch_size, ijmap, guide_weight):
79
+ image, mask, global_mask = inputs
80
+ result = patchmatch.inpaint_regularity(
81
+ image,
82
+ mask,
83
+ ijmap,
84
+ global_mask=global_mask,
85
+ patch_size=patch_size,
86
+ guide_weight=guide_weight,
87
+ )
88
+ assert_only_holes_changed(result, image, mask, global_mask)
89
+
90
+
91
+ @settings(deadline=None)
92
+ @given(images_and_masks(), hnp.arrays(np.uint8, (24, 24, 3)), patch_sizes)
93
+ def test_colors_under_global_mask_do_not_matter(inputs, colors, patch_size):
94
+ image, mask, global_mask = inputs
95
+ if global_mask is None:
96
+ global_mask = np.zeros(image.shape[:2], dtype=bool)
97
+ if mask is None:
98
+ mask = white_pixels(image)
99
+ colors = colors[: image.shape[0], : image.shape[1]]
100
+ recolored = np.where(global_mask[..., None], colors, image)
101
+ ijmap = image.astype(np.float32) / 255
102
+ kwargs = {"global_mask": global_mask, "patch_size": patch_size}
103
+
104
+ holes = mask & ~global_mask
105
+ actual = fills(recolored, mask, ijmap, **kwargs)
106
+ expected = fills(image, mask, ijmap, **kwargs)
107
+ for result, reference in zip(actual, expected, strict=True):
108
+ np.testing.assert_array_equal(result[holes], reference[holes])
109
+
110
+
111
+ @settings(deadline=None)
112
+ @given(images_and_masks(), hnp.arrays(np.uint8, (24, 24, 3)), patch_sizes)
113
+ def test_colors_under_the_holes_do_not_matter(inputs, colors, patch_size):
114
+ image, mask, global_mask = inputs
115
+ if mask is None:
116
+ mask = white_pixels(image)
117
+ holes = mask if global_mask is None else mask & ~global_mask
118
+ colors = colors[: image.shape[0], : image.shape[1]]
119
+ recolored = np.where(holes[..., None], colors, image)
120
+ ijmap = image.astype(np.float32) / 255
121
+ kwargs = {"global_mask": global_mask, "patch_size": patch_size}
122
+
123
+ assert_same_fills(
124
+ fills(recolored, mask, ijmap, **kwargs), fills(image, mask, ijmap, **kwargs)
125
+ )
126
+
127
+
128
+ @settings(deadline=None)
129
+ @given(images_and_masks(), patch_sizes)
130
+ def test_empty_global_mask_changes_nothing(inputs, patch_size):
131
+ image, mask, _ = inputs
132
+ ijmap = image.astype(np.float32) / 255
133
+ empty = np.zeros(image.shape[:2], dtype=bool)
134
+
135
+ assert_same_fills(
136
+ fills(image, mask, ijmap, global_mask=empty, patch_size=patch_size),
137
+ fills(image, mask, ijmap, patch_size=patch_size),
138
+ )
139
+
140
+
141
+ @settings(deadline=None)
142
+ @given(images_and_masks(), hnp.arrays(np.uint8, (24, 24)), patch_sizes)
143
+ def test_only_the_holes_of_the_mask_matter(inputs, values, patch_size):
144
+ """Any non-zero value marks a hole, and globally masked pixels are none."""
145
+ image, mask, global_mask = inputs
146
+ if global_mask is None:
147
+ global_mask = np.zeros(image.shape[:2], dtype=bool)
148
+ holes = white_pixels(image) if mask is None else mask
149
+ holes = holes & ~global_mask
150
+ values = values[: image.shape[0], : image.shape[1]]
151
+ # any value under the global mask, a non-zero one in the holes, else zero
152
+ other_mask = np.where(global_mask, values, np.where(holes, values | 1, 0))
153
+ ijmap = image.astype(np.float32) / 255
154
+ kwargs = {"global_mask": global_mask, "patch_size": patch_size}
155
+
156
+ assert_same_fills(
157
+ fills(image, other_mask, ijmap, **kwargs),
158
+ fills(image, mask, ijmap, **kwargs),
159
+ )
@@ -1,95 +0,0 @@
1
- """Property-based tests: the invariants of the inpainting on random inputs."""
2
-
3
- import numpy as np
4
- from hypothesis import given, settings
5
- from hypothesis import strategies as st
6
- from hypothesis.extra import numpy as hnp
7
-
8
- import patchmatch
9
-
10
- sizes = st.integers(1, 24)
11
- patch_sizes = st.integers(1, 4)
12
-
13
-
14
- @st.composite
15
- def images_and_masks(draw):
16
- """An image, a hole mask (None: the white pixels) and an optional global mask."""
17
- shape = (draw(sizes), draw(sizes))
18
- image = draw(hnp.arrays(np.uint8, (*shape, 3)))
19
- mask = draw(st.none() | hnp.arrays(np.bool_, shape))
20
- global_mask = draw(st.none() | hnp.arrays(np.bool_, shape))
21
- return image, mask, global_mask
22
-
23
-
24
- def assert_only_holes_changed(result, image, mask, global_mask):
25
- assert result.shape == image.shape
26
- assert result.dtype == np.uint8
27
- holes = (image == 255).all(axis=-1) if mask is None else mask
28
- if global_mask is not None:
29
- holes = holes & ~global_mask
30
- np.testing.assert_array_equal(result[~holes], image[~holes])
31
-
32
-
33
- @settings(deadline=None)
34
- @given(images_and_masks(), patch_sizes)
35
- def test_inpaint(inputs, patch_size):
36
- image, mask, global_mask = inputs
37
-
38
- def inpaint():
39
- patchmatch.set_random_seed(0)
40
- return patchmatch.inpaint(
41
- image, mask, global_mask=global_mask, patch_size=patch_size
42
- )
43
-
44
- result = inpaint()
45
- assert_only_holes_changed(result, image, mask, global_mask)
46
- np.testing.assert_array_equal(inpaint(), result)
47
-
48
-
49
- @settings(deadline=None)
50
- @given(
51
- images_and_masks(),
52
- patch_sizes,
53
- hnp.arrays(
54
- np.float32,
55
- st.tuples(sizes, sizes, st.just(3)),
56
- elements=st.floats(-2, 2, width=32),
57
- ),
58
- st.floats(0, 10),
59
- )
60
- def test_inpaint_regularity(inputs, patch_size, ijmap, guide_weight):
61
- image, mask, global_mask = inputs
62
- result = patchmatch.inpaint_regularity(
63
- image,
64
- mask,
65
- ijmap,
66
- global_mask=global_mask,
67
- patch_size=patch_size,
68
- guide_weight=guide_weight,
69
- )
70
- assert_only_holes_changed(result, image, mask, global_mask)
71
-
72
-
73
- @settings(deadline=None)
74
- @given(images_and_masks(), hnp.arrays(np.uint8, (24, 24, 3)), patch_sizes)
75
- def test_colors_under_global_mask_do_not_matter(inputs, colors, patch_size):
76
- image, mask, global_mask = inputs
77
- if global_mask is None:
78
- global_mask = np.zeros(image.shape[:2], dtype=bool)
79
- if mask is None:
80
- mask = (image == 255).all(axis=-1)
81
- colors = colors[: image.shape[0], : image.shape[1]]
82
- recolored = np.where(global_mask[..., None], colors, image)
83
- ijmap = image.astype(np.float32) / 255
84
-
85
- def fills(source):
86
- patchmatch.set_random_seed(0)
87
- kwargs = {"global_mask": global_mask, "patch_size": patch_size}
88
- return (
89
- patchmatch.inpaint(source, mask, **kwargs),
90
- patchmatch.inpaint_regularity(source, mask, ijmap, **kwargs),
91
- )
92
-
93
- holes = mask & ~global_mask
94
- for result, expected in zip(fills(recolored), fills(image), strict=True):
95
- np.testing.assert_array_equal(result[holes], expected[holes])
File without changes