PyPatchMatch 2.2.0b2__tar.gz → 2.2.0rc1__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.0b2 → pypatchmatch-2.2.0rc1}/CLAUDE.md +3 -1
  2. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/PKG-INFO +11 -6
  3. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/README.md +9 -4
  4. pypatchmatch-2.2.0rc1/examples/images/camera_aged.png +0 -0
  5. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/forest_pruned.bmp +0 -0
  6. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/_version.py +3 -3
  7. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/inpaint.cpp +53 -17
  8. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/masked_image.cpp +13 -9
  9. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/masked_image.h +19 -13
  10. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/nnf.cpp +19 -40
  11. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/pyproject.toml +1 -0
  12. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/evaluate_inpainting.py +135 -64
  13. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_e2e.py +10 -2
  14. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/CMakeLists.txt +0 -0
  15. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/LICENSE +0 -0
  16. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/SECURITY.md +0 -0
  17. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/camera_damaged.png +0 -0
  18. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/camera_damaged_mask.png +0 -0
  19. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/chelsea.png +0 -0
  20. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/__init__.py +0 -0
  21. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/_lib.py +0 -0
  22. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/inpaint.h +0 -0
  23. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/nnf.h +0 -0
  24. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/pyinterface.cpp +0 -0
  25. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/pyinterface.h +0 -0
  26. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/patch_match.py +0 -0
  27. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/py.typed +0 -0
  28. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/build_opencv.py +0 -0
  29. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/render_readme_example.py +0 -0
  30. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/conftest.py +0 -0
  31. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_patch_match.py +0 -0
  32. {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_properties.py +0 -0
@@ -134,4 +134,6 @@ CMake installs the shared library into the package directory.
134
134
  `invoke-ai/PyPatchMatch`. `gh` resolves the default repository to upstream, so pass
135
135
  `-R mauwii/PyPatchMatch`.
136
136
  - 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.
137
+ pre-releases) builds the wheels and publishes them to PyPI. The workflow `Fill
138
+ quality` compares the release with the last stable one; read its summary before
139
+ approving the job `publish`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.2.0b2
3
+ Version: 2.2.0rc1
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.0b2/examples/images/readme_example.jpg)
54
+ ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.2.0rc1/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)
@@ -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
@@ -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.0b2'
22
- __version_tuple__ = version_tuple = (2, 2, 0, 'b2')
21
+ __version__ = version = '2.2.0rc1'
22
+ __version_tuple__ = version_tuple = (2, 2, 0, 'rc1')
23
23
 
24
- __commit_id__ = commit_id = 'ge960c214c'
24
+ __commit_id__ = commit_id = 'ge40b76e89'
@@ -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])
@@ -152,16 +152,15 @@ MaskedImage MaskedImage::upsample(int new_w, int new_h, const cv::Mat &new_globa
152
152
  return ret;
153
153
  }
154
154
 
155
- void MaskedImage::compute_image_gradients() const
155
+ void MaskedImage::compute_features() const
156
156
  {
157
- if (m_image_grad_computed)
157
+ if (m_features_computed)
158
158
  {
159
159
  return;
160
160
  }
161
161
 
162
162
  const auto size = m_image.size();
163
- m_image_grady = cv::Mat(size, CV_8UC3);
164
- m_image_gradx = cv::Mat(size, CV_8UC3);
163
+ m_features = cv::Mat(size, CV_8UC(kFeatureChannels));
165
164
 
166
165
  // The border replicates its pixels, so that patches over the outermost line compare it as well.
167
166
  for (int y = 0; y < size.height; ++y)
@@ -179,15 +178,20 @@ void MaskedImage::compute_image_gradients() const
179
178
  const unsigned char *above = get_image(up, x);
180
179
  const unsigned char *next = get_image(y, right);
181
180
  const unsigned char *previous = get_image(y, left);
182
- auto *grady = m_image_grady.ptr<unsigned char>(y, x);
183
- auto *gradx = m_image_gradx.ptr<unsigned char>(y, x);
181
+ auto *features = m_features.ptr<unsigned char>(y, x);
182
+ std::fill_n(features, kFeatureChannels, 0);
183
+ const unsigned char *color = get_image(y, x);
184
184
  for (int c = 0; c < 3; ++c)
185
185
  {
186
- grady[c] = static_cast<unsigned char>(skip_y ? 128 : below[c] / 2 - above[c] / 2 + 128);
187
- gradx[c] = static_cast<unsigned char>(skip_x ? 128 : next[c] / 2 - previous[c] / 2 + 128);
186
+ features[c] = color[c];
187
+ features[3 + c] = static_cast<unsigned char>(skip_x ? 128 : next[c] / 2 - previous[c] / 2 + 128);
188
+ features[6 + c] = static_cast<unsigned char>(skip_y ? 128 : below[c] / 2 - above[c] / 2 + 128);
188
189
  }
190
+ if (m_has_texture)
191
+ features[kTextureFeature] = m_texture.at<unsigned char>(y, x);
192
+ features[kMaskedFeature] = static_cast<unsigned char>(is_masked(y, x) || is_globally_masked(y, x));
189
193
  }
190
194
  }
191
195
 
192
- m_image_grad_computed = true;
196
+ m_features_computed = true;
193
197
  }
@@ -52,15 +52,16 @@ public:
52
52
  {
53
53
  return m_has_global_mask;
54
54
  }
55
- const cv::Mat &grady() const
55
+ // Per pixel the colors, the gradients along x and y, the texture and whether it is masked, packed so that
56
+ // a distance reads one place per pixel.
57
+ static constexpr int kFeatureChannels = 12;
58
+ static constexpr int kColorAndGradientFeatures = 9;
59
+ static constexpr int kTextureFeature = 9;
60
+ static constexpr int kMaskedFeature = 10;
61
+ const cv::Mat &features() const
56
62
  {
57
- assert(m_image_grad_computed);
58
- return m_image_grady;
59
- }
60
- const cv::Mat &gradx() const
61
- {
62
- assert(m_image_grad_computed);
63
- return m_image_gradx;
63
+ assert(m_features_computed);
64
+ return m_features;
64
65
  }
65
66
 
66
67
  // The texture energy that the distances compare (CV_8UC1, see Inpainting), or empty.
@@ -77,6 +78,7 @@ public:
77
78
  {
78
79
  m_texture = texture;
79
80
  m_has_texture = !texture.empty();
81
+ m_features_computed = false;
80
82
  }
81
83
 
82
84
  void init_global_mask_mat()
@@ -85,12 +87,14 @@ public:
85
87
  m_global_mask.setTo(cv::Scalar(0));
86
88
  m_has_global_mask = !m_global_mask.empty();
87
89
  m_hole_sums_computed = false;
90
+ m_features_computed = false;
88
91
  }
89
92
  void set_global_mask_mat(const cv::Mat &other)
90
93
  {
91
94
  m_global_mask = other;
92
95
  m_has_global_mask = !other.empty();
93
96
  m_hole_sums_computed = false;
97
+ m_features_computed = false;
94
98
  }
95
99
 
96
100
  bool is_masked(int y, int x) const
@@ -105,16 +109,19 @@ public:
105
109
  {
106
110
  m_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
107
111
  m_hole_sums_computed = false;
112
+ m_features_computed = false;
108
113
  }
109
114
  void set_global_mask(int y, int x, bool value)
110
115
  {
111
116
  m_global_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
112
117
  m_hole_sums_computed = false;
118
+ m_features_computed = false;
113
119
  }
114
120
  void clear_mask()
115
121
  {
116
122
  m_mask.setTo(cv::Scalar(0));
117
123
  m_hole_sums_computed = false;
124
+ m_features_computed = false;
118
125
  }
119
126
 
120
127
  const unsigned char *get_image(int y, int x) const
@@ -130,7 +137,7 @@ public:
130
137
  MaskedImage downsample() const;
131
138
  MaskedImage upsample(int new_w, int new_h) const;
132
139
  MaskedImage upsample(int new_w, int new_h, const cv::Mat &new_global_mask) const;
133
- void compute_image_gradients() const;
140
+ void compute_features() const;
134
141
 
135
142
  private:
136
143
  cv::Mat m_image;
@@ -140,10 +147,9 @@ private:
140
147
  // cv::Mat::empty() is not inline in OpenCV 5, too slow to call for every pixel
141
148
  bool m_has_global_mask = false;
142
149
  bool m_has_texture = false;
143
- // computed on first use by compute_image_gradients
144
- mutable cv::Mat m_image_grady;
145
- mutable cv::Mat m_image_gradx;
146
- mutable bool m_image_grad_computed = false;
150
+ // computed on first use by compute_features
151
+ mutable cv::Mat m_features;
152
+ mutable bool m_features_computed = false;
147
153
  // computed on first use by contains_mask: the masked pixels outside the global mask
148
154
  // above and left of each pixel. Copies share them with the masks, so change a mask only
149
155
  // through an image that nothing has copied yet, as downsample and upsample do.
@@ -3,6 +3,7 @@
3
3
  #include <algorithm>
4
4
  #include <cassert>
5
5
  #include <cmath>
6
+ #include <cstddef>
6
7
  #include <cstdint>
7
8
  #include <random>
8
9
  #include <typeinfo>
@@ -68,66 +69,44 @@ namespace
68
69
  bool textured;
69
70
  };
70
71
 
71
- // One row of a source and a target image, both inside their images. The gradients must be computed.
72
+ // One row of a source and a target image, both inside their images. The features must be computed.
72
73
  class RowPair
73
74
  {
74
75
  public:
75
76
  RowPair(const ImagePair &images, int ys, int yt)
76
77
  : m_source_width(images.source_size.width), m_target_width(images.target_size.width),
77
- m_si(images.source->image().ptr<unsigned char>(ys, 0)),
78
- m_ti(images.target->image().ptr<unsigned char>(yt, 0)),
79
- m_sm(images.source->mask().ptr<unsigned char>(ys, 0)),
80
- m_tm(images.target->mask().ptr<unsigned char>(yt, 0)),
81
- m_sgm(
82
- images.source->has_global_mask() ? images.source->global_mask().ptr<unsigned char>(ys, 0) : nullptr),
83
- m_tgm(
84
- images.target->has_global_mask() ? images.target->global_mask().ptr<unsigned char>(yt, 0) : nullptr),
85
- m_sgy(images.source->grady().ptr<unsigned char>(ys, 0)),
86
- m_tgy(images.target->grady().ptr<unsigned char>(yt, 0)),
87
- m_sgx(images.source->gradx().ptr<unsigned char>(ys, 0)),
88
- m_tgx(images.target->gradx().ptr<unsigned char>(yt, 0)),
89
- m_st(images.textured ? images.source->texture().ptr<unsigned char>(ys) : nullptr),
90
- m_tt(images.textured ? images.target->texture().ptr<unsigned char>(yt) : nullptr)
78
+ m_source(images.source->features().ptr<unsigned char>(ys)),
79
+ m_target(images.target->features().ptr<unsigned char>(yt)), m_textured(images.textured)
91
80
  {
92
81
  }
93
82
 
94
83
  // The SSD over the colors and gradients, kSSDScale where a pixel is masked or outside its image.
95
84
  int cost(int xs, int xt) const
96
85
  {
97
- // The bounds first: the masks are read only inside the image.
98
- if (xs < 0 || xs >= m_source_width || xt < 0 || xt >= m_target_width || m_sm[xs] || m_tm[xt] ||
99
- (m_sgm && m_sgm[xs]) || (m_tgm && m_tgm[xt]))
86
+ // The bounds first: the features are read only inside the image.
87
+ if (xs < 0 || xs >= m_source_width || xt < 0 || xt >= m_target_width)
88
+ return PatchSSDDistanceMetric::kSSDScale;
89
+ const unsigned char *s = m_source + static_cast<std::ptrdiff_t>(xs) * MaskedImage::kFeatureChannels;
90
+ const unsigned char *t = m_target + static_cast<std::ptrdiff_t>(xt) * MaskedImage::kFeatureChannels;
91
+ if (s[MaskedImage::kMaskedFeature] || t[MaskedImage::kMaskedFeature])
100
92
  return PatchSSDDistanceMetric::kSSDScale;
101
93
 
102
94
  int ssd = 0;
103
- for (int c = 0; c < 3; ++c)
104
- {
105
- ssd += pow2(m_si[xs * 3 + c] - m_ti[xt * 3 + c]);
106
- ssd += pow2(m_sgx[xs * 3 + c] - m_tgx[xt * 3 + c]);
107
- ssd += pow2(m_sgy[xs * 3 + c] - m_tgy[xt * 3 + c]);
108
- }
95
+ for (int k = 0; k < MaskedImage::kColorAndGradientFeatures; ++k)
96
+ ssd += pow2(s[k] - t[k]);
109
97
  // Smooth fills matched smooth sources and stayed flat, so a target pixel with less than half the
110
98
  // texture of its source pixel costs extra. Not a two-sided difference: it removed mortar joints.
111
- if (m_st)
112
- ssd += 2 * pow2(std::max(m_st[xs] / 2 - m_tt[xt], 0));
99
+ if (m_textured)
100
+ ssd += 2 * pow2(std::max(s[MaskedImage::kTextureFeature] / 2 - t[MaskedImage::kTextureFeature], 0));
113
101
  return ssd;
114
102
  }
115
103
 
116
104
  private:
117
105
  int m_source_width;
118
106
  int m_target_width;
119
- const unsigned char *m_si;
120
- const unsigned char *m_ti;
121
- const unsigned char *m_sm;
122
- const unsigned char *m_tm;
123
- const unsigned char *m_sgm;
124
- const unsigned char *m_tgm;
125
- const unsigned char *m_sgy;
126
- const unsigned char *m_tgy;
127
- const unsigned char *m_sgx;
128
- const unsigned char *m_tgx;
129
- const unsigned char *m_st; // the textures, nullptr unless both images have one
130
- const unsigned char *m_tt;
107
+ const unsigned char *m_source;
108
+ const unsigned char *m_target;
109
+ bool m_textured; // false unless both images have a texture
131
110
  };
132
111
 
133
112
  // The costs of the row of the patches around s and t.
@@ -168,8 +147,8 @@ namespace
168
147
  const double limit =
169
148
  (bound + 1.0) * PatchSSDDistanceMetric::kSSDScale * side * side / PatchDistanceMetric::kDistanceScale;
170
149
 
171
- source.compute_image_gradients();
172
- target.compute_image_gradients();
150
+ source.compute_features();
151
+ target.compute_features();
173
152
 
174
153
  const ImagePair images(source, target);
175
154
  for (int dy = -patch_size; dy <= patch_size; ++dy)
@@ -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",
@@ -19,12 +19,15 @@ 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
 
25
28
  Besides the table, it writes examples/images/evaluation.html (git-ignored): the holes,
26
- the fills (of --baseline too) and the originals side by side. It embeds the images of
27
- examples/images/local/ as well, so keep it out of the repository.
29
+ the fills (of --baseline too) and the originals side by side, cut to the holes. It
30
+ embeds the images of examples/images/local/ as well, so keep it out of the repository.
28
31
 
29
32
  The e2e tests use measure() to check the seam and the detail of forest_pruned.bmp.
30
33
 
@@ -34,7 +37,15 @@ Columns, means over the seeds:
34
37
  detail color difference of neighbors inside the holes, relative to their
35
38
  surroundings; 0.85 to 1.1 in the original images, lower is blurrier
36
39
  error mean color error against the original on images reduced to 1/8, which
37
- compares the structure of the fill rather than its pixels
40
+ compares the structure of the fill rather than its pixels; it rates a flat
41
+ fill better than a texture whose structures are shifted
42
+ edges difference of the gradient directions from the original, in histograms
43
+ weighted by magnitude per 32x32 cell of the holes; lowest for the right
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
38
49
  fills with --baseline: whether the fills of all seeds are byte-identical to the
39
50
  baseline's, as they have to be after a change that only makes it faster
40
51
  """
@@ -43,18 +54,20 @@ from __future__ import annotations
43
54
 
44
55
  import argparse
45
56
  import base64
46
- import ctypes
57
+ import functools
47
58
  import hashlib
48
59
  import html
49
60
  import io
50
61
  import sys
51
62
  import time
52
63
  from collections.abc import Iterator
64
+ from concurrent.futures import Future, ProcessPoolExecutor
53
65
  from datetime import datetime
54
66
  from pathlib import Path
55
67
  from typing import NamedTuple, TextIO
56
68
 
57
69
  import numpy as np
70
+ from numpy.lib.stride_tricks import sliding_window_view
58
71
  from PIL import Image
59
72
 
60
73
  import patchmatch
@@ -73,7 +86,7 @@ HOLES = {
73
86
  "chelsea": (52, 215, 24, 40),
74
87
  "pattern": (320, 320, 40, 40),
75
88
  }
76
- COLUMNS = ("seam", "detail", "error")
89
+ COLUMNS = ("seam", "detail", "error", "edges", "variety")
77
90
 
78
91
 
79
92
  class Case(NamedTuple):
@@ -182,6 +195,55 @@ def coarse_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> flo
182
195
  return float(np.abs(reduce(image)[cells] - reduce(truth)[cells]).mean())
183
196
 
184
197
 
198
+ EDGE_CELL = 32
199
+ EDGE_BINS = 8
200
+
201
+
202
+ def edge_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
203
+ """Difference of the gradient directions, weighted by magnitude, per cell of holes."""
204
+ ys, xs = np.nonzero(holes)
205
+ cells = (ys // EDGE_CELL) * (holes.shape[1] // EDGE_CELL + 1) + xs // EDGE_CELL
206
+ length = (int(cells.max()) + 1) * EDGE_BINS
207
+
208
+ def histograms(array: np.ndarray) -> np.ndarray:
209
+ gy, gx = np.gradient(array.astype(float).mean(axis=2))
210
+ bins = (np.arctan2(gy, gx) % np.pi * EDGE_BINS / np.pi).astype(int) % EDGE_BINS
211
+ weights = np.hypot(gx, gy)[holes]
212
+ return np.bincount(cells * EDGE_BINS + bins[holes], weights, length)
213
+
214
+ return float(np.abs(histograms(image) - histograms(truth)).sum() / len(ys))
215
+
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
+
185
247
  def measure(
186
248
  result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
187
249
  ) -> dict[str, float]:
@@ -202,26 +264,40 @@ def measure(
202
264
  }
203
265
  if truth is not None:
204
266
  values["error"] = coarse_error(result, truth, holes)
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)
205
271
  return values
206
272
 
207
273
 
208
- def evaluate(library: ctypes.CDLL, case: Case, patch_size: int, seeds: int) -> Result:
209
- patch_match._lib = library
210
- mask = case.holes.astype(np.uint8)
211
- 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)
212
284
  start = time.perf_counter()
213
- for seed in range(seeds):
214
- patchmatch.set_random_seed(seed)
215
- fills.append(
216
- patchmatch.inpaint(
217
- case.image, mask, global_mask=case.global_mask, patch_size=patch_size
218
- )
219
- )
220
- rows.append(measure(fills[-1], case.holes, case.truth))
221
- 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)
222
298
  values = {key: float(np.mean([row[key] for row in rows])) for key in rows[0]}
223
299
  digest = hashlib.sha256(b"".join(fill.tobytes() for fill in fills)).hexdigest()
224
- return Result(values, seconds, fills[0], digest)
300
+ return Result(values, float(np.mean(seconds)), fills[0], digest)
225
301
 
226
302
 
227
303
  def identical(results: list[Result]) -> str:
@@ -286,19 +362,13 @@ figure { margin: 0; cursor: zoom-in; }
286
362
  figcaption { font-size: 13px; color: var(--muted); margin-top: 4px;
287
363
  font-variant-numeric: tabular-nums; }
288
364
  figcaption b { color: var(--fg); font-weight: 600; margin-right: 6px; }
289
- .frame { position: relative; overflow: hidden; width: 100%;
290
- aspect-ratio: var(--crop); background: var(--line); }
291
- .frame img { position: absolute; display: block; max-width: none; height: auto;
292
- left: var(--left); top: var(--top); width: var(--width); }
293
- body.whole .frame { aspect-ratio: var(--full); }
294
- body.whole .frame img { left: 0; top: 0; width: 100%; }
365
+ figure img { display: block; width: 100%; height: auto; }
295
366
  body.pixels img { image-rendering: pixelated; }
296
367
  #viewer { position: fixed; inset: 0; z-index: 10; display: flex;
297
368
  flex-direction: column; align-items: center; justify-content: center; gap: 10px;
298
369
  background: rgb(0 0 0 / 0.9); cursor: zoom-out; }
299
370
  #viewer[hidden] { display: none; }
300
- #viewer .frame { width: min(94vw, calc(86vh * var(--crop))); cursor: pointer; }
301
- body.whole #viewer .frame { width: min(94vw, calc(86vh * var(--full))); }
371
+ #viewer img { width: 94vw; height: 86vh; object-fit: contain; cursor: pointer; }
302
372
  #viewer p { color: #ddd; font-size: 14px; margin: 0; }
303
373
  """
304
374
 
@@ -310,7 +380,7 @@ function show(i) {
310
380
  index = (i + figures.length) % figures.length;
311
381
  const figure = figures[index];
312
382
  viewer.querySelector(".slot").replaceChildren(
313
- figure.querySelector(".frame").cloneNode(true));
383
+ figure.querySelector("img").cloneNode());
314
384
  viewer.querySelector("p").textContent = figure.dataset.title
315
385
  + " (" + (index + 1) + "/" + figures.length + ")";
316
386
  }
@@ -322,7 +392,7 @@ for (const figure of document.querySelectorAll("figure")) {
322
392
  });
323
393
  }
324
394
  viewer.addEventListener("click", (event) => {
325
- if (event.target.closest(".frame")) show(index + 1);
395
+ if (event.target.closest("img")) show(index + 1);
326
396
  else viewer.hidden = true;
327
397
  });
328
398
  document.addEventListener("keydown", (event) => {
@@ -333,12 +403,10 @@ document.addEventListener("keydown", (event) => {
333
403
  else return;
334
404
  event.preventDefault();
335
405
  });
336
- for (const name of ["whole", "pixels"]) {
337
- const box = document.getElementById(name);
338
- const apply = () => document.body.classList.toggle(name, box.checked);
339
- box.addEventListener("change", apply);
340
- apply(); // browsers restore the checkboxes on reload
341
- }
406
+ const pixels = document.getElementById("pixels");
407
+ const apply = () => document.body.classList.toggle("pixels", pixels.checked);
408
+ pixels.addEventListener("change", apply);
409
+ apply(); // browsers restore the checkbox on reload
342
410
  """
343
411
 
344
412
 
@@ -362,23 +430,11 @@ def crop_box(holes: np.ndarray) -> tuple[int, int, int, int]:
362
430
  return int(x0), int(y0), int(x1 - x0), int(y1 - y0)
363
431
 
364
432
 
365
- def frame(image: np.ndarray, box: tuple[int, int, int, int]) -> str:
366
- """The image, cut to ``box`` by CSS unless the whole images are shown."""
367
- height, width = image.shape[:2]
368
- x, y, w, h = box
369
- style = (
370
- f"--crop:{w / h:.5f};--full:{width / height:.5f};--width:{100 * width / w:.4f}%;"
371
- f"--left:{-100 * x / w:.4f}%;--top:{-100 * y / h:.4f}%"
372
- )
373
- source = f"data:image/png;base64,{png(image)}"
374
- return f'<div class="frame" style="{style}"><img alt="" src="{source}"></div>'
375
-
376
-
377
433
  def verdict(key: str, old: float, new: float) -> str:
378
434
  """CSS class of ``new`` compared with ``old``: better, worse or none."""
379
435
  if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
380
436
  return ""
381
- 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
382
438
  old, new = abs(old - 1), abs(new - 1)
383
439
  return "better" if new < old else "worse"
384
440
 
@@ -412,7 +468,7 @@ def caption(label: str, result: Result | None) -> str:
412
468
 
413
469
 
414
470
  def section(case: Case, results: list[Result], labels: list[str]) -> str:
415
- box = crop_box(case.holes)
471
+ x, y, w, h = crop_box(case.holes)
416
472
  shown = case.image.copy()
417
473
  if case.global_mask is not None:
418
474
  shown[case.global_mask] //= 2
@@ -422,7 +478,8 @@ def section(case: Case, results: list[Result], labels: list[str]) -> str:
422
478
  versions.append(("original", case.truth, None))
423
479
  figures = "".join(
424
480
  f'<figure data-title="{html.escape(case.name)}: {label}">'
425
- f"{frame(image, box)}<figcaption>{caption(label, result)}</figcaption></figure>"
481
+ f'<img alt="" src="data:image/png;base64,{png(image[y : y + h, x : x + w])}">'
482
+ f"<figcaption>{caption(label, result)}</figcaption></figure>"
426
483
  for label, image, result in versions
427
484
  )
428
485
  height, width = case.holes.shape
@@ -456,7 +513,6 @@ def write_report(
456
513
  f"<title>patchmatch evaluation</title><style>{STYLE}</style></head><body>"
457
514
  f"<main><header><h1>patchmatch evaluation</h1>{meta}</header>"
458
515
  '<div class="controls">'
459
- '<label><input type="checkbox" id="whole"> whole images</label>'
460
516
  '<label><input type="checkbox" id="pixels"> show pixels</label>'
461
517
  '<p class="hint">Click an image to enlarge it; click it, &larr; or &rarr; '
462
518
  "switches between the versions, Esc closes.</p></div>"
@@ -498,9 +554,9 @@ def markdown(rows: list[tuple[Case, list[Result]]], title: str) -> str:
498
554
  ]
499
555
  lines += [
500
556
  "",
501
- "Means over the seeds. seam and detail are best at 1.0, as in the original "
502
- "images, error at 0. With a baseline: baseline → current, 🟢 better and 🔴 worse "
503
- "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 %).",
504
560
  ]
505
561
  return "\n".join(lines)
506
562
 
@@ -521,6 +577,7 @@ def main() -> None:
521
577
  parser.add_argument("--patch-size", type=int, default=3)
522
578
  parser.add_argument("--seeds", type=int, default=3)
523
579
  parser.add_argument("--cases", help="comma-separated names, default: all")
580
+ parser.add_argument("--jobs", type=int, help="processes, default: one per CPU")
524
581
  parser.add_argument(
525
582
  "--markdown", action="store_true", help="print the table as Markdown"
526
583
  )
@@ -537,24 +594,38 @@ def main() -> None:
537
594
  if args.baseline:
538
595
  libraries.insert(0, _lib.load_library(args.baseline.resolve()))
539
596
  labels = ["baseline", "current"]
597
+ paths = [lib._name for lib in libraries]
540
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]
541
600
 
542
601
  out = sys.stderr if args.markdown else sys.stdout
543
602
  print(f"patch_size={args.patch_size} seeds={args.seeds}", file=out)
544
603
  fills = "fills" if args.baseline else ""
545
- header = f"{'case':14} {'seam':16}{'detail':16}{'error':16}{'time':16}{fills}"
604
+ header = f"{'case':14} {''.join(f'{key:16}' for key in COLUMNS)}{'time':16}{fills}"
546
605
  print(header.rstrip(), file=out)
547
606
  rows = []
548
- for case in cases():
549
- if selected and case.name not in selected:
550
- continue
551
- results = [
552
- 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
553
617
  ]
554
- print_row(case.name, results, out)
555
- rows.append((case, results))
556
-
557
- 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)
558
629
  write_report(rows, labels, meta)
559
630
  print(f"report: {REPORT}", file=out)
560
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