PyPatchMatch 2.2.0b3__tar.gz → 2.2.0rc2__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 (32) hide show
  1. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/CLAUDE.md +7 -3
  2. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/PKG-INFO +14 -9
  3. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/README.md +12 -7
  4. pypatchmatch-2.2.0rc2/examples/images/camera_aged.png +0 -0
  5. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/forest_pruned.bmp +0 -0
  6. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/_version.py +3 -3
  7. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/inpaint.cpp +53 -17
  8. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/pyproject.toml +5 -2
  9. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/evaluate_inpainting.py +99 -32
  10. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_e2e.py +10 -2
  11. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/CMakeLists.txt +0 -0
  12. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/LICENSE +0 -0
  13. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/SECURITY.md +0 -0
  14. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/camera_damaged.png +0 -0
  15. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/camera_damaged_mask.png +0 -0
  16. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/chelsea.png +0 -0
  17. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/__init__.py +0 -0
  18. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/_lib.py +0 -0
  19. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/inpaint.h +0 -0
  20. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/masked_image.cpp +0 -0
  21. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/masked_image.h +0 -0
  22. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/nnf.cpp +0 -0
  23. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/nnf.h +0 -0
  24. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/pyinterface.cpp +0 -0
  25. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/pyinterface.h +0 -0
  26. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/patch_match.py +0 -0
  27. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/py.typed +0 -0
  28. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/build_opencv.py +0 -0
  29. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/render_readme_example.py +0 -0
  30. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/conftest.py +0 -0
  31. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_patch_match.py +0 -0
  32. {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_properties.py +0 -0
@@ -102,8 +102,10 @@ CMake installs the shared library into the package directory.
102
102
  members, `k` constants. Commit a reformat separately and add its hash to
103
103
  `.git-blame-ignore-revs`.
104
104
  - The package ships `py.typed`; public functions are fully annotated. mypy checks
105
- `patchmatch` and `tests` in strict mode (`[tool.mypy]`); the tests have to use the
106
- API correctly but need no annotations of their own. A deliberately wrong argument
105
+ `examples`, `patchmatch`, `scripts` and `tests` in strict mode (`[tool.mypy]`); the
106
+ tests have to use the API correctly but need no annotations of their own. Only the
107
+ examples annotate their locals and pass arguments by keyword, for readers who copy
108
+ them. A deliberately wrong argument
107
109
  goes through a test parameter instead of a `# type: ignore`.
108
110
  - Backwards compatibility: `from patchmatch import patch_match` and
109
111
  `patch_match.CShapeT`/`CMatT` keep working (tested).
@@ -134,4 +136,6 @@ CMake installs the shared library into the package directory.
134
136
  `invoke-ai/PyPatchMatch`. `gh` resolves the default repository to upstream, so pass
135
137
  `-R mauwii/PyPatchMatch`.
136
138
  - Releases: creating a GitHub release with a tag like `v2.0.0` (or `v2.0.0rc2` for
137
- pre-releases) builds the wheels and publishes them to PyPI.
139
+ pre-releases) builds the wheels and publishes them to PyPI. The workflow `Fill
140
+ quality` compares the release with the last stable one; read its summary before
141
+ approving the job `publish`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.2.0b3
3
+ Version: 2.2.0rc2
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>
@@ -30,13 +30,18 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  # PatchMatch based Inpainting
32
32
 
33
- [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=mauwii_PyPatchMatch&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
34
- [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
35
33
  [![PyPI](https://img.shields.io/pypi/v/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
36
- [![Downloads](https://static.pepy.tech/badge/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
34
+ [![Python](https://img.shields.io/pypi/pyversions/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
35
+ [![Downloads](https://img.shields.io/pepy/dt/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
36
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
37
+
38
+ [![Tests](https://img.shields.io/github/actions/workflow/status/mauwii/PyPatchMatch/ci.yml?branch=main&label=tests)](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml?query=branch%3Amain)
39
+ [![Coverage](https://img.shields.io/sonar/coverage/mauwii_PyPatchMatch?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/component_measures?id=mauwii_PyPatchMatch&metric=coverage)
40
+ [![Quality Gate](https://img.shields.io/sonar/quality_gate/mauwii_PyPatchMatch?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
41
+
42
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
37
43
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
38
44
  [![ponytail](https://img.shields.io/badge/over--engineering-ponytail_audited-111111)](https://github.com/DietrichGebert/ponytail)
39
- [![caveman](https://img.shields.io/badge/agent-caveman-F0A63C)](https://github.com/JuliusBrussee/caveman)
40
45
 
41
46
  This library implements the PatchMatch based inpainting algorithm. It provides both C++
42
47
  and Python interfaces. This implementation is heavily based on the implementation by
@@ -46,7 +51,7 @@ with some bug fixes, and updates. This repository continues
46
51
  the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
47
52
  [invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
48
53
 
49
- ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.2.0b3/examples/images/readme_example.jpg)
54
+ ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.2.0rc2/examples/images/readme_example.jpg)
50
55
 
51
56
  From left to right: the photo, the input of
52
57
  [examples/py_example.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example.py)
@@ -59,9 +64,9 @@ pip install PyPatchMatch
59
64
  ```
60
65
 
61
66
  Wheels for Linux (glibc, x86_64 and aarch64), macOS 11+ (arm64 and x86_64) and Windows
62
- (x64) ship the compiled library with a statically linked OpenCV, so no compiler or
63
- OpenCV installation is needed. On other platforms, e.g. Alpine Linux or Windows on ARM,
64
- pip builds from the source distribution, which requires a C++17 compiler, CMake and the
67
+ (x64 and ARM64) ship the compiled library with a statically linked OpenCV, so no
68
+ compiler or OpenCV installation is needed. On other platforms, e.g. Alpine Linux, pip
69
+ builds from the source distribution, which requires a C++17 compiler, CMake and the
65
70
  OpenCV development files (e.g. `apt install libopencv-dev` or `brew install opencv`).
66
71
 
67
72
  If that build fails, PyPatchMatch is installed without the native library instead of
@@ -1,12 +1,17 @@
1
1
  # PatchMatch based Inpainting
2
2
 
3
- [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=mauwii_PyPatchMatch&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
4
- [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
5
3
  [![PyPI](https://img.shields.io/pypi/v/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
6
- [![Downloads](https://static.pepy.tech/badge/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
4
+ [![Python](https://img.shields.io/pypi/pyversions/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
5
+ [![Downloads](https://img.shields.io/pepy/dt/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
7
+
8
+ [![Tests](https://img.shields.io/github/actions/workflow/status/mauwii/PyPatchMatch/ci.yml?branch=main&label=tests)](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml?query=branch%3Amain)
9
+ [![Coverage](https://img.shields.io/sonar/coverage/mauwii_PyPatchMatch?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/component_measures?id=mauwii_PyPatchMatch&metric=coverage)
10
+ [![Quality Gate](https://img.shields.io/sonar/quality_gate/mauwii_PyPatchMatch?server=https%3A%2F%2Fsonarcloud.io)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
11
+
12
+ [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
7
13
  [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
8
14
  [![ponytail](https://img.shields.io/badge/over--engineering-ponytail_audited-111111)](https://github.com/DietrichGebert/ponytail)
9
- [![caveman](https://img.shields.io/badge/agent-caveman-F0A63C)](https://github.com/JuliusBrussee/caveman)
10
15
 
11
16
  This library implements the PatchMatch based inpainting algorithm. It provides both C++
12
17
  and Python interfaces. This implementation is heavily based on the implementation by
@@ -29,9 +34,9 @@ pip install PyPatchMatch
29
34
  ```
30
35
 
31
36
  Wheels for Linux (glibc, x86_64 and aarch64), macOS 11+ (arm64 and x86_64) and Windows
32
- (x64) ship the compiled library with a statically linked OpenCV, so no compiler or
33
- OpenCV installation is needed. On other platforms, e.g. Alpine Linux or Windows on ARM,
34
- pip builds from the source distribution, which requires a C++17 compiler, CMake and the
37
+ (x64 and ARM64) ship the compiled library with a statically linked OpenCV, so no
38
+ compiler or OpenCV installation is needed. On other platforms, e.g. Alpine Linux, pip
39
+ builds from the source distribution, which requires a C++17 compiler, CMake and the
35
40
  OpenCV development files (e.g. `apt install libopencv-dev` or `brew install opencv`).
36
41
 
37
42
  If that build fails, PyPatchMatch is installed without the native library instead of
@@ -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.2.0b3'
22
- __version_tuple__ = version_tuple = (2, 2, 0, 'b3')
21
+ __version__ = version = '2.2.0rc2'
22
+ __version_tuple__ = version_tuple = (2, 2, 0, 'rc2')
23
23
 
24
- __commit_id__ = commit_id = 'gc0f3bbfe6'
24
+ __commit_id__ = commit_id = 'gd6affc5f5'
@@ -369,18 +369,15 @@ namespace
369
369
 
370
370
  // Replaces the vote of the pixel if the new one weighs more. It is stored like the
371
371
  // sums of _weighted_copy, so the maximization step divides it by its weight.
372
- inline void _best_copy(const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
372
+ inline void _best_copy(
373
+ const unsigned char *color, const cv::Vec3d &shift, cv::Mat &target, int yt, int xt, double weight)
373
374
  {
374
- if (source.is_masked(ys, xs))
375
- return;
376
-
377
- auto source_ptr = source.get_image(ys, xs);
378
375
  auto target_ptr = target.ptr<double>(yt, xt);
379
376
  if (weight <= target_ptr[3])
380
377
  return;
381
378
 
382
379
  for (int c = 0; c < 3; ++c)
383
- target_ptr[c] = static_cast<double>(source_ptr[c]) * weight;
380
+ target_ptr[c] = (static_cast<double>(color[c]) + shift[c]) * weight;
384
381
  target_ptr[3] = weight;
385
382
  }
386
383
 
@@ -451,6 +448,7 @@ namespace
451
448
  const int xp = m_nnf->at(i, j, 1);
452
449
  if (m_holes && !votes_for_hole(m_source2target ? yp : i, m_source2target ? xp : j, patch_size))
453
450
  return;
451
+ const cv::Vec3d shift = m_holes ? color_shift(i, j, yp, xp, patch_size) : cv::Vec3d();
454
452
  for (int di = -patch_size; di <= patch_size; ++di)
455
453
  {
456
454
  for (int dj = -patch_size; dj <= patch_size; ++dj)
@@ -467,12 +465,47 @@ namespace
467
465
  continue;
468
466
  // Near ties go to the patch centered closest, not the first cast, which made blocks.
469
467
  const double offset2 = static_cast<double>(di) * di + static_cast<double>(dj) * dj;
470
- cast_pair(ys, xs, yt, xt, weight, weight * (1 - 1e-5 * offset2));
468
+ cast_pair(ys, xs, yt, xt, weight, weight * (1 - 1e-5 * offset2), shift);
471
469
  }
472
470
  }
473
471
  }
474
472
 
475
473
  private:
474
+ // The mean color difference of the voted image to the voting source over the pixels known in both patches.
475
+ // The votes of the finest levels add it: their sources lie elsewhere in the image, and with their colors
476
+ // the fill of a vignetted photo came out lighter than the colors the coarser levels had laid out.
477
+ cv::Vec3d color_shift(int i, int j, int yp, int xp, int patch_size) const
478
+ {
479
+ const MaskedImage &source = m_nnf->source();
480
+ const MaskedImage &target = m_nnf->target();
481
+ cv::Vec3d sum;
482
+ int count = 0;
483
+ for (int di = -patch_size; di <= patch_size; ++di)
484
+ {
485
+ for (int dj = -patch_size; dj <= patch_size; ++dj)
486
+ {
487
+ const int ys = i + di;
488
+ const int xs = j + dj;
489
+ const int yt = yp + di;
490
+ const int xt = xp + dj;
491
+ if (ys < 0 || ys >= m_source_size.height || xs < 0 || xs >= m_source_size.width || yt < 0 ||
492
+ yt >= m_target_size.height || xt < 0 || xt >= m_target_size.width)
493
+ continue;
494
+ if (!is_known(source, ys, xs) || !is_known(target, yt, xt))
495
+ continue;
496
+ const auto *s = source.get_image(ys, xs);
497
+ const auto *t = target.get_image(yt, xt);
498
+ for (int c = 0; c < 3; ++c)
499
+ sum[c] += s[c] - t[c];
500
+ ++count;
501
+ }
502
+ }
503
+ if (count == 0)
504
+ return {};
505
+ // The voted image is the source of the coherence field and the target of the completeness field.
506
+ return sum * ((m_source2target ? -1.0 : 1.0) / count);
507
+ }
508
+
476
509
  // Whether the patch around (y, x) of the voted image, scaled like the votes, has a hole.
477
510
  bool votes_for_hole(int y, int x, int patch_size) const
478
511
  {
@@ -482,7 +515,7 @@ namespace
482
515
  scale * (std::int64_t{y} + patch_size + 1) - 1, scale * (std::int64_t{x} + patch_size + 1) - 1);
483
516
  }
484
517
 
485
- void cast_pair(int ys, int xs, int yt, int xt, double weight, double best_weight) const
518
+ void cast_pair(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
486
519
  {
487
520
  if (!m_source2target)
488
521
  {
@@ -491,27 +524,30 @@ namespace
491
524
  }
492
525
  if (!m_upscaled)
493
526
  {
494
- copy(ys, xs, yt, xt, weight, best_weight);
527
+ copy(ys, xs, yt, xt, weight, best_weight, shift);
495
528
  return;
496
529
  }
497
530
  for (int uy = 0; uy < 2; ++uy)
498
531
  for (int ux = 0; ux < 2; ++ux)
499
- copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight, best_weight);
532
+ copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight, best_weight, shift);
500
533
  }
501
534
 
502
- void copy(int ys, int xs, int yt, int xt, double weight, double best_weight) const
535
+ void copy(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
503
536
  {
504
537
  if (m_best_only)
505
- _best_copy(*m_source, ys, xs, *m_vote, yt, xt, best_weight);
538
+ {
539
+ if (!m_source->is_masked(ys, xs))
540
+ _best_copy(m_source->get_image(ys, xs), shift, *m_vote, yt, xt, best_weight);
541
+ }
506
542
  else if (m_split)
507
- split_copy(ys, xs, yt, xt, weight, best_weight);
543
+ split_copy(ys, xs, yt, xt, weight, best_weight, shift);
508
544
  else
509
545
  _weighted_copy(*m_source, ys, xs, *m_vote, yt, xt, weight);
510
546
  }
511
547
 
512
- // Adds the low frequencies and the texture of the source pixel to the sums, and keeps the rest if the
513
- // vote weighs most.
514
- void split_copy(int ys, int xs, int yt, int xt, double weight, double best_weight) const
548
+ // Adds the shifted low frequencies and the texture of the source pixel to the sums, and keeps the rest if
549
+ // the vote weighs most.
550
+ void split_copy(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
515
551
  {
516
552
  if (!is_known(*m_source, ys, xs))
517
553
  return;
@@ -520,7 +556,7 @@ namespace
520
556
  const auto *low = m_low.ptr<unsigned char>(ys, xs);
521
557
  auto *vote = m_vote->ptr<double>(yt, xt);
522
558
  for (int c = 0; c < 3; ++c)
523
- vote[c] += low[c] * weight;
559
+ vote[c] += (low[c] + shift[c]) * weight;
524
560
  vote[3] += weight;
525
561
  vote[8] += m_source->texture().at<unsigned char>(ys, xs) * weight;
526
562
  if (best_weight <= vote[7])
@@ -60,6 +60,7 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
60
60
  # The release wheels are built from the sdist, so it has to contain the tests
61
61
  # and their input images; _version.py is generated and therefore git-ignored.
62
62
  sdist.include = [
63
+ "examples/images/camera_aged.png",
63
64
  "examples/images/camera_damaged.png",
64
65
  "examples/images/camera_damaged_mask.png",
65
66
  "examples/images/chelsea.png",
@@ -119,6 +120,8 @@ environment = { OpenCV_ROOT = "/tmp/opencv", MACOSX_DEPLOYMENT_TARGET = "11.0" }
119
120
  environment = { OpenCV_ROOT = "C:/opencv" }
120
121
 
121
122
  [tool.uv]
123
+ # the cooldown of dependabot.yml for uv lock --upgrade
124
+ exclude-newer = "7 days"
122
125
  # Rebuild the package whenever the native sources or the git version change.
123
126
  cache-keys = [
124
127
  { file = "CMakeLists.txt" },
@@ -130,7 +133,6 @@ cache-keys = [
130
133
  [tool.ruff]
131
134
  extend-exclude = ["patchmatch/csrc"]
132
135
  line-length = 88
133
- target-version = "py311"
134
136
 
135
137
  [tool.ruff.lint]
136
138
  select = ["B", "C4", "E", "F", "I", "RUF", "SIM", "UP", "W"]
@@ -143,7 +145,7 @@ patch = ["subprocess"]
143
145
  fail_under = 95
144
146
 
145
147
  [tool.mypy]
146
- files = ["patchmatch", "tests"]
148
+ files = ["examples", "patchmatch", "scripts", "tests"]
147
149
  strict = true
148
150
 
149
151
  [[tool.mypy.overrides]]
@@ -162,6 +164,7 @@ warn_return_any = false
162
164
  addopts = ["--cov-branch", "--cov-report=term:skip-covered", "--cov=patchmatch"]
163
165
  # shows which test hangs before the job timeout kills the run
164
166
  faulthandler_timeout = 300
167
+ filterwarnings = ["error"]
165
168
  markers = ["e2e: end-to-end tests of the user workflows (deselect with -m 'not e2e')"]
166
169
  minversion = "9.0"
167
170
  python_files = ["test_*.py"]
@@ -19,6 +19,9 @@ Usage:
19
19
  uv run python scripts/evaluate_inpainting.py --baseline main/libpatchmatch.dylib
20
20
  uv run python scripts/evaluate_inpainting.py --patch-size 15 --seeds 1 --cases brick
21
21
 
22
+ The inpaintings run in parallel processes, one per CPU unless --jobs says otherwise.
23
+ They slow each other down, so time a speed-up with --jobs 1.
24
+
22
25
  --markdown prints the table as Markdown and moves the plain one to stderr; the CI
23
26
  workflow "Fill quality" redirects it to the job summary.
24
27
 
@@ -39,6 +42,10 @@ Columns, means over the seeds:
39
42
  edges difference of the gradient directions from the original, in histograms
40
43
  weighted by magnitude per 32x32 cell of the holes; lowest for the right
41
44
  texture, also when shifted, highest for a flat fill
45
+ variety difference of the 8x8 patches of the holes, without their mean color, to
46
+ the closest one that does not overlap them, relative to the original;
47
+ lower where the fill repeats its sources or blurs (the tiles of
48
+ grass-large: about 0.25), higher for fragments
42
49
  fills with --baseline: whether the fills of all seeds are byte-identical to the
43
50
  baseline's, as they have to be after a change that only makes it faster
44
51
  """
@@ -47,18 +54,20 @@ from __future__ import annotations
47
54
 
48
55
  import argparse
49
56
  import base64
50
- import ctypes
57
+ import functools
51
58
  import hashlib
52
59
  import html
53
60
  import io
54
61
  import sys
55
62
  import time
56
63
  from collections.abc import Iterator
64
+ from concurrent.futures import Future, ProcessPoolExecutor
57
65
  from datetime import datetime
58
66
  from pathlib import Path
59
67
  from typing import NamedTuple, TextIO
60
68
 
61
69
  import numpy as np
70
+ from numpy.lib.stride_tricks import sliding_window_view
62
71
  from PIL import Image
63
72
 
64
73
  import patchmatch
@@ -77,7 +86,7 @@ HOLES = {
77
86
  "chelsea": (52, 215, 24, 40),
78
87
  "pattern": (320, 320, 40, 40),
79
88
  }
80
- COLUMNS = ("seam", "detail", "error", "edges")
89
+ COLUMNS = ("seam", "detail", "error", "edges", "variety")
81
90
 
82
91
 
83
92
  class Case(NamedTuple):
@@ -100,12 +109,12 @@ def load(path: Path) -> np.ndarray:
100
109
 
101
110
 
102
111
  def white(image: np.ndarray) -> np.ndarray:
103
- return (image == 255).all(axis=2)
112
+ return np.all(image == 255, axis=2)
104
113
 
105
114
 
106
115
  def ellipse(shape: tuple[int, ...], cy: int, cx: int, ry: int, rx: int) -> np.ndarray:
107
116
  yy, xx = np.mgrid[: shape[0], : shape[1]]
108
- return ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 < 1
117
+ return np.asarray(((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 < 1)
109
118
 
110
119
 
111
120
  def cut(
@@ -205,6 +214,36 @@ def edge_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float
205
214
  return float(np.abs(histograms(image) - histograms(truth)).sum() / len(ys))
206
215
 
207
216
 
217
+ PATCH = 8
218
+
219
+
220
+ def patches(image: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
221
+ """The PATCHxPATCH patches inside ``mask`` on a grid, flattened, and their corners."""
222
+ grid = (slice(None, None, PATCH // 2),) * 2
223
+ windows = sliding_window_view(image, (PATCH, PATCH), axis=(0, 1))[grid]
224
+ inside = sliding_window_view(mask, (PATCH, PATCH))[grid].all(axis=(2, 3))
225
+ flat = windows[inside].reshape(int(inside.sum()), 3, -1).astype(np.float32)
226
+ # without their mean color, which the votes shift for repeated sources too
227
+ flat -= flat.mean(axis=2, keepdims=True)
228
+ return flat.reshape(len(flat), -1), np.argwhere(inside) * (PATCH // 2)
229
+
230
+
231
+ def variety(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
232
+ def mean(array: np.ndarray) -> float:
233
+ """Mean distance of the patches to the closest one that does not overlap them."""
234
+ found, corners = patches(array, holes)
235
+ norms = (found**2).sum(axis=1)
236
+ closest = []
237
+ for start in range(0, len(found), 512): # bounds the memory of the distances
238
+ chunk, where = found[start : start + 512], corners[start : start + 512]
239
+ distances = norms[start : start + 512, None] + norms - 2 * chunk @ found.T
240
+ distances[np.abs(where[:, None] - corners).max(axis=2) < PATCH] = np.inf
241
+ closest.append(distances.min(axis=1))
242
+ return float(np.sqrt(np.maximum(np.concatenate(closest), 0)).mean())
243
+
244
+ return mean(image) / mean(truth)
245
+
246
+
208
247
  def measure(
209
248
  result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
210
249
  ) -> dict[str, float]:
@@ -226,26 +265,39 @@ def measure(
226
265
  if truth is not None:
227
266
  values["error"] = coarse_error(result, truth, holes)
228
267
  values["edges"] = edge_error(result, truth, holes)
268
+ # holes thinner than a patch, such as scratches, have none to compare
269
+ if sliding_window_view(holes, (PATCH, PATCH)).all(axis=(2, 3)).any():
270
+ values["variety"] = variety(result, truth, holes)
229
271
  return values
230
272
 
231
273
 
232
- def evaluate(library: ctypes.CDLL, case: Case, patch_size: int, seeds: int) -> Result:
233
- patch_match._lib = library
234
- mask = case.holes.astype(np.uint8)
235
- rows, fills = [], []
274
+ Seed = tuple[np.ndarray, dict[str, float], float] # fill, measures, seconds
275
+
276
+ library = functools.cache(_lib.load_library)
277
+
278
+
279
+ def fill_seed(path: str, case: Case, patch_size: int, seed: int) -> Seed:
280
+ """One inpainting, in a worker process."""
281
+ # processes, not threads: the seed and patch_match._lib are global
282
+ patch_match._lib = library(Path(path))
283
+ patchmatch.set_random_seed(seed)
236
284
  start = time.perf_counter()
237
- for seed in range(seeds):
238
- patchmatch.set_random_seed(seed)
239
- fills.append(
240
- patchmatch.inpaint(
241
- case.image, mask, global_mask=case.global_mask, patch_size=patch_size
242
- )
243
- )
244
- rows.append(measure(fills[-1], case.holes, case.truth))
245
- seconds = (time.perf_counter() - start) / seeds
285
+ fill = patchmatch.inpaint(
286
+ case.image,
287
+ case.holes.astype(np.uint8),
288
+ global_mask=case.global_mask,
289
+ patch_size=patch_size,
290
+ )
291
+ seconds = time.perf_counter() - start
292
+ return fill, measure(fill, case.holes, case.truth), seconds
293
+
294
+
295
+ def evaluate(jobs: list[Future[Seed]]) -> Result:
296
+ """The result of one case and library from the jobs of all seeds, in order."""
297
+ fills, rows, seconds = zip(*(job.result() for job in jobs), strict=True)
246
298
  values = {key: float(np.mean([row[key] for row in rows])) for key in rows[0]}
247
299
  digest = hashlib.sha256(b"".join(fill.tobytes() for fill in fills)).hexdigest()
248
- return Result(values, seconds, fills[0], digest)
300
+ return Result(values, float(np.mean(seconds)), fills[0], digest)
249
301
 
250
302
 
251
303
  def identical(results: list[Result]) -> str:
@@ -382,7 +434,7 @@ def verdict(key: str, old: float, new: float) -> str:
382
434
  """CSS class of ``new`` compared with ``old``: better, worse or none."""
383
435
  if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
384
436
  return ""
385
- if key in ("seam", "detail"): # best at 1.0, as in the original images
437
+ if key in ("seam", "detail", "variety"): # best at 1.0, as in the original images
386
438
  old, new = abs(old - 1), abs(new - 1)
387
439
  return "better" if new < old else "worse"
388
440
 
@@ -420,7 +472,7 @@ def section(case: Case, results: list[Result], labels: list[str]) -> str:
420
472
  shown = case.image.copy()
421
473
  if case.global_mask is not None:
422
474
  shown[case.global_mask] //= 2
423
- versions = [("holes", shown, None)]
475
+ versions: list[tuple[str, np.ndarray, Result | None]] = [("holes", shown, None)]
424
476
  versions += [(label, r.fill, r) for label, r in zip(labels, results, strict=True)]
425
477
  if case.truth is not None:
426
478
  versions.append(("original", case.truth, None))
@@ -502,9 +554,9 @@ def markdown(rows: list[tuple[Case, list[Result]]], title: str) -> str:
502
554
  ]
503
555
  lines += [
504
556
  "",
505
- "Means over the seeds. seam and detail are best at 1.0, as in the original "
506
- "images, error and edges at 0. With a baseline: baseline → current, 🟢 better "
507
- "and 🔴 worse by more than 2 % (time: 10 %).",
557
+ "Means over the seeds. seam, detail and variety are best at 1.0, as in the "
558
+ "original images, error and edges at 0. With a baseline: baseline → current, "
559
+ "🟢 better and 🔴 worse by more than 2 % (time: 10 %).",
508
560
  ]
509
561
  return "\n".join(lines)
510
562
 
@@ -525,6 +577,7 @@ def main() -> None:
525
577
  parser.add_argument("--patch-size", type=int, default=3)
526
578
  parser.add_argument("--seeds", type=int, default=3)
527
579
  parser.add_argument("--cases", help="comma-separated names, default: all")
580
+ parser.add_argument("--jobs", type=int, help="processes, default: one per CPU")
528
581
  parser.add_argument(
529
582
  "--markdown", action="store_true", help="print the table as Markdown"
530
583
  )
@@ -541,7 +594,9 @@ def main() -> None:
541
594
  if args.baseline:
542
595
  libraries.insert(0, _lib.load_library(args.baseline.resolve()))
543
596
  labels = ["baseline", "current"]
597
+ paths = [lib._name for lib in libraries]
544
598
  selected = set(args.cases.split(",")) if args.cases else None
599
+ chosen = [case for case in cases() if not selected or case.name in selected]
545
600
 
546
601
  out = sys.stderr if args.markdown else sys.stdout
547
602
  print(f"patch_size={args.patch_size} seeds={args.seeds}", file=out)
@@ -549,16 +604,28 @@ def main() -> None:
549
604
  header = f"{'case':14} {''.join(f'{key:16}' for key in COLUMNS)}{'time':16}{fills}"
550
605
  print(header.rstrip(), file=out)
551
606
  rows = []
552
- for case in cases():
553
- if selected and case.name not in selected:
554
- continue
555
- results = [
556
- evaluate(lib, case, args.patch_size, args.seeds) for lib in libraries
607
+ pool = ProcessPoolExecutor(args.jobs)
608
+ try:
609
+ # all at once, the seeds outside, so that the libraries run under equal load
610
+ jobs = [
611
+ {
612
+ (index, seed): pool.submit(fill_seed, path, case, args.patch_size, seed)
613
+ for seed in range(args.seeds)
614
+ for index, path in enumerate(paths)
615
+ }
616
+ for case in chosen
557
617
  ]
558
- print_row(case.name, results, out)
559
- rows.append((case, results))
560
-
561
- meta = describe(args, labels, [lib._name for lib in libraries])
618
+ for case, futures in zip(chosen, jobs, strict=True):
619
+ results = [
620
+ evaluate([futures[index, seed] for seed in range(args.seeds)])
621
+ for index in range(len(paths))
622
+ ]
623
+ print_row(case.name, results, out)
624
+ rows.append((case, results))
625
+ finally:
626
+ pool.shutdown(cancel_futures=True) # Ctrl+C stops after the running jobs
627
+
628
+ meta = describe(args, labels, paths)
562
629
  write_report(rows, labels, meta)
563
630
  print(f"report: {REPORT}", file=out)
564
631
  if args.markdown:
@@ -137,13 +137,21 @@ def test_global_mask_is_not_used_as_source(pruned_image):
137
137
 
138
138
 
139
139
  def test_restore_aged_photo():
140
- """Scratches, holes and a torn corner, light but not white, given as a mask."""
140
+ """Scratches, holes and a torn corner, light but not white, given as a mask.
141
+
142
+ The finest levels averaged the colors of sources from elsewhere in the vignetted
143
+ photo, so the torn corner came out lighter (error 4.6 to 6.1 with ten seeds).
144
+ """
141
145
  source = Image.open(IMAGES / "camera_damaged.png")
142
146
  mask = Image.open(IMAGES / "camera_damaged_mask.png")
147
+ holes = np.array(mask) > 0
143
148
 
144
149
  result = patchmatch.inpaint(source, mask, patch_size=3)
145
150
 
146
- assert_plausible_fill(np.array(source), result, holes=np.array(mask) > 0)
151
+ assert_plausible_fill(np.array(source), result, holes=holes)
152
+ truth = evaluation.load(IMAGES / "camera_aged.png")
153
+ error = evaluation.coarse_error(result, truth, holes)
154
+ assert error < 4.2, error
147
155
 
148
156
 
149
157
  @pytest.mark.parametrize(
File without changes