PyPatchMatch 2.0.0rc2__tar.gz → 2.0.0rc4__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 (23) hide show
  1. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/CMakeLists.txt +20 -1
  2. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/PKG-INFO +13 -2
  3. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/README.md +12 -1
  4. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/_version.py +3 -3
  5. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/inpaint.cpp +81 -28
  6. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/inpaint.h +4 -2
  7. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/masked_image.cpp +4 -1
  8. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/masked_image.h +14 -5
  9. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/nnf.cpp +23 -18
  10. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/nnf.h +20 -7
  11. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/pyinterface.cpp +14 -10
  12. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/csrc/pyinterface.h +4 -3
  13. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/scripts/build_opencv.py +4 -1
  14. pypatchmatch-2.0.0rc4/scripts/evaluate_inpainting.py +450 -0
  15. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/tests/test_e2e.py +38 -0
  16. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/LICENSE +0 -0
  17. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/examples/images/forest_pruned.bmp +0 -0
  18. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/__init__.py +0 -0
  19. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/_lib.py +0 -0
  20. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/patch_match.py +0 -0
  21. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/patchmatch/py.typed +0 -0
  22. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/pyproject.toml +0 -0
  23. {pypatchmatch-2.0.0rc2 → pypatchmatch-2.0.0rc4}/tests/test_patch_match.py +0 -0
@@ -4,6 +4,7 @@ project(patchmatch LANGUAGES CXX)
4
4
  option(PATCHMATCH_BUILD_EXAMPLES "Build the C++ example (requires OpenCV imgcodecs)" OFF)
5
5
  option(PATCHMATCH_WITH_HIGHGUI "Enable the debug visualization of Inpainting::run (requires OpenCV highgui)" OFF)
6
6
  option(PATCHMATCH_FAST_MATH "Compile with fast-math optimizations" ON)
7
+ option(PATCHMATCH_SANITIZE "Build with AddressSanitizer and UndefinedBehaviorSanitizer, without fast math (GCC, Clang)" OFF)
7
8
 
8
9
  if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
9
10
  set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
@@ -32,13 +33,20 @@ add_library(patchmatch SHARED
32
33
  target_include_directories(patchmatch PUBLIC patchmatch/csrc)
33
34
  target_link_libraries(patchmatch PUBLIC opencv_core)
34
35
 
36
+ # CI adds CMAKE_COMPILE_WARNING_AS_ERROR; the OpenCV headers are system headers
37
+ if(MSVC)
38
+ target_compile_options(patchmatch PRIVATE /W4)
39
+ else()
40
+ target_compile_options(patchmatch PRIVATE -Wall -Wextra -Wpedantic -Wshadow)
41
+ endif()
42
+
35
43
  if(MSVC AND NOT OpenCV_SHARED)
36
44
  # A static OpenCV (as built by scripts/build_opencv.py) uses the static MSVC runtime.
37
45
  set_property(TARGET patchmatch PROPERTY
38
46
  MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
39
47
  endif()
40
48
 
41
- if(PATCHMATCH_FAST_MATH)
49
+ if(PATCHMATCH_FAST_MATH AND NOT PATCHMATCH_SANITIZE)
42
50
  if(MSVC)
43
51
  target_compile_options(patchmatch PRIVATE /fp:fast)
44
52
  else()
@@ -46,6 +54,17 @@ if(PATCHMATCH_FAST_MATH)
46
54
  endif()
47
55
  endif()
48
56
 
57
+ if(PATCHMATCH_SANITIZE)
58
+ if(MSVC)
59
+ message(FATAL_ERROR "PATCHMATCH_SANITIZE needs GCC or Clang")
60
+ endif()
61
+ # GCC's -fsanitize=undefined leaves out float-cast-overflow
62
+ set(PATCHMATCH_SANITIZERS -fsanitize=address,undefined,float-cast-overflow)
63
+ target_compile_options(patchmatch PRIVATE ${PATCHMATCH_SANITIZERS}
64
+ -fno-sanitize-recover=all -fno-omit-frame-pointer -g)
65
+ target_link_options(patchmatch PRIVATE ${PATCHMATCH_SANITIZERS})
66
+ endif()
67
+
49
68
  if(PATCHMATCH_WITH_HIGHGUI)
50
69
  target_compile_definitions(patchmatch PRIVATE PATCHMATCH_WITH_HIGHGUI)
51
70
  target_link_libraries(patchmatch PRIVATE opencv_imgproc opencv_highgui)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.0.0rc2
3
+ Version: 2.0.0rc4
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,6 +30,8 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  # PatchMatch based Inpainting
32
32
 
33
+ [![CI](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml)
34
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=mauwii_PyPatchMatch&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
33
35
  [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
34
36
  [![PyPI](https://img.shields.io/pypi/v/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
35
37
  [![Downloads](https://static.pepy.tech/badge/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
@@ -76,7 +78,10 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
76
78
 
77
79
  The mask must have the same height and width as the image. If `mask` is omitted, all
78
80
  pure white pixels are treated as holes. Only the holes are filled; all other pixels keep
79
- their values. The optional keyword argument `global_mask`, in the same format, marks
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
80
85
  pixels that are neither filled nor used as a source; they keep their values as well (see
81
86
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
82
87
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
@@ -121,8 +126,14 @@ uv sync # create .venv, build the library, install dev de
121
126
  uv run pre-commit install # enable ruff and the other hooks on commit
122
127
  uv run pytest # run the test suite
123
128
  uv run pytest -m "not e2e" # only the fast unit tests
129
+ uv run python scripts/evaluate_inpainting.py # measure the fill quality
124
130
  ```
125
131
 
132
+ The evaluation cuts holes into the images of `examples/images`, compares the fills with
133
+ the original content and writes `examples/images/evaluation.html`, which shows them side
134
+ by side. `--baseline` evaluates another build of the library as well, e.g. of the main
135
+ branch; the docstring of the script explains the columns.
136
+
126
137
  Releases are published to PyPI by creating a GitHub release; the version is taken from
127
138
  its tag (e.g. `v2.0.0`).
128
139
 
@@ -1,5 +1,7 @@
1
1
  # PatchMatch based Inpainting
2
2
 
3
+ [![CI](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml)
4
+ [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=mauwii_PyPatchMatch&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
3
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blueviolet.svg)](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
4
6
  [![PyPI](https://img.shields.io/pypi/v/PyPatchMatch)](https://pypi.org/project/PyPatchMatch/)
5
7
  [![Downloads](https://static.pepy.tech/badge/pypatchmatch)](https://pepy.tech/projects/pypatchmatch)
@@ -46,7 +48,10 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
46
48
 
47
49
  The mask must have the same height and width as the image. If `mask` is omitted, all
48
50
  pure white pixels are treated as holes. Only the holes are filled; all other pixels keep
49
- their values. The optional keyword argument `global_mask`, in the same format, marks
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
50
55
  pixels that are neither filled nor used as a source; they keep their values as well (see
51
56
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
52
57
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
@@ -91,8 +96,14 @@ uv sync # create .venv, build the library, install dev de
91
96
  uv run pre-commit install # enable ruff and the other hooks on commit
92
97
  uv run pytest # run the test suite
93
98
  uv run pytest -m "not e2e" # only the fast unit tests
99
+ uv run python scripts/evaluate_inpainting.py # measure the fill quality
94
100
  ```
95
101
 
102
+ The evaluation cuts holes into the images of `examples/images`, compares the fills with
103
+ the original content and writes `examples/images/evaluation.html`, which shows them side
104
+ by side. `--baseline` evaluates another build of the library as well, e.g. of the main
105
+ branch; the docstring of the script explains the columns.
106
+
96
107
  Releases are published to PyPI by creating a GitHub release; the version is taken from
97
108
  its tag (e.g. `v2.0.0`).
98
109
 
@@ -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.0rc2'
22
- __version_tuple__ = version_tuple = (2, 0, 0, 'rc2')
21
+ __version__ = version = '2.0.0rc4'
22
+ __version_tuple__ = version_tuple = (2, 0, 0, 'rc4')
23
23
 
24
- __commit_id__ = commit_id = 'geb61e5d37'
24
+ __commit_id__ = commit_id = 'g6f35e1d41'
@@ -1,8 +1,9 @@
1
1
  #include <algorithm>
2
+ #include <array>
2
3
  #include <iostream>
3
4
  #ifdef PATCHMATCH_WITH_HIGHGUI
4
- #include <opencv2/imgproc.hpp>
5
5
  #include <opencv2/highgui.hpp>
6
+ #include <opencv2/imgproc.hpp>
6
7
  #endif
7
8
 
8
9
  #include "inpaint.h"
@@ -11,17 +12,16 @@ namespace
11
12
  {
12
13
  std::vector<double> make_distance2similarity()
13
14
  {
14
- double base[11] = {1.0, 0.99, 0.96, 0.83, 0.38, 0.11, 0.02, 0.005, 0.0006, 0.0001, 0};
15
- int length = (PatchDistanceMetric::kDistanceScale + 1);
15
+ constexpr std::array<double, 11> base = {1.0, 0.99, 0.96, 0.83, 0.38, 0.11, 0.02, 0.005, 0.0006, 0.0001, 0};
16
+ const std::size_t length = static_cast<std::size_t>(PatchDistanceMetric::kDistanceScale) + 1;
16
17
  std::vector<double> table(length);
17
- for (int i = 0; i < length; ++i)
18
+ for (std::size_t i = 0; i < length; ++i)
18
19
  {
19
- double t = (double)i / length;
20
- int j = (int)(100 * t);
21
- int k = j + 1;
22
- double vj = (j < 11) ? base[j] : 0;
23
- double vk = (k < 11) ? base[k] : 0;
24
- table[i] = vj + (100 * t - j) * (vk - vj);
20
+ const double t = static_cast<double>(i) / static_cast<double>(length);
21
+ const auto j = static_cast<std::size_t>(100 * t);
22
+ if (j >= base.size() - 1)
23
+ continue; // the table is zero from here on
24
+ table[i] = base[j] + (100 * t - static_cast<double>(j)) * (base[j + 1] - base[j]);
25
25
  }
26
26
  return table;
27
27
  }
@@ -34,7 +34,8 @@ namespace
34
34
  return table;
35
35
  }
36
36
 
37
- inline void _weighted_copy(const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
37
+ inline void _weighted_copy(
38
+ const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
38
39
  {
39
40
  if (source.is_masked(ys, xs))
40
41
  return;
@@ -48,10 +49,29 @@ namespace
48
49
  target_ptr[c] += static_cast<double>(source_ptr[c]) * weight;
49
50
  target_ptr[3] += weight;
50
51
  }
51
- }
52
+
53
+ // Replaces the vote of the pixel if the new one weighs more. It is stored like the
54
+ // sums of _weighted_copy, so the maximization step divides it by its weight.
55
+ inline void _best_copy(const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
56
+ {
57
+ if (source.is_masked(ys, xs))
58
+ return;
59
+ if (source.is_globally_masked(ys, xs))
60
+ return;
61
+
62
+ auto source_ptr = source.get_image(ys, xs);
63
+ auto target_ptr = target.ptr<double>(yt, xt);
64
+ if (weight <= target_ptr[3])
65
+ return;
66
+
67
+ for (int c = 0; c < 3; ++c)
68
+ target_ptr[c] = static_cast<double>(source_ptr[c]) * weight;
69
+ target_ptr[3] = weight;
70
+ }
71
+ } // namespace
52
72
 
53
73
  /**
54
- * This algorithme uses a version proposed by Xavier Philippeau.
74
+ * This algorithm uses a version proposed by Xavier Philippeau.
55
75
  */
56
76
 
57
77
  Inpainting::Inpainting(cv::Mat image, cv::Mat mask, const PatchDistanceMetric *metric)
@@ -70,7 +90,8 @@ void Inpainting::_initialize_pyramid()
70
90
  {
71
91
  auto source = m_initial;
72
92
  m_pyramid.push_back(source);
73
- while (source.size().height > m_distance_metric->patch_size() && source.size().width > m_distance_metric->patch_size())
93
+ while (source.size().height > m_distance_metric->patch_size() &&
94
+ source.size().width > m_distance_metric->patch_size())
74
95
  {
75
96
  source = source.downsample();
76
97
  m_pyramid.push_back(source);
@@ -80,7 +101,7 @@ void Inpainting::_initialize_pyramid()
80
101
  cv::Mat Inpainting::run(bool verbose, bool verbose_visualize, unsigned int random_seed)
81
102
  {
82
103
  NearestNeighborField::seed_random(random_seed);
83
- const int nr_levels = m_pyramid.size();
104
+ const auto nr_levels = static_cast<int>(m_pyramid.size());
84
105
 
85
106
  MaskedImage source, target;
86
107
  for (int level = nr_levels - 1; level >= 0; --level)
@@ -125,9 +146,9 @@ cv::Mat Inpainting::run(bool verbose, bool verbose_visualize, unsigned int rando
125
146
  target = _expectation_maximization(source, target, level, verbose);
126
147
  }
127
148
 
128
- // Only the holes are filled. The votes of overlapping patches also change the known
129
- // pixels near the holes, and the pyramid leaves globally masked pixels black, which
130
- // are neither filled nor used as a source. Keep the input values of both.
149
+ // Only the holes are filled. The maximization step of level 0 keeps the known pixels,
150
+ // but the pyramid leaves globally masked pixels black, which are neither filled nor
151
+ // used as a source. Copy every pixel outside the holes from the input.
131
152
  // A plain loop instead of OpenCV matrix expressions: those pull OpenCV's whole
132
153
  // expression module into the statically linked library, 40 % more on disk.
133
154
  cv::Mat result = target.image();
@@ -191,7 +212,8 @@ MaskedImage Inpainting::_expectation_maximization(MaskedImage source, MaskedImag
191
212
  if (level >= 1 && iter_em == nr_iters_em - 1)
192
213
  {
193
214
  new_source = m_pyramid[level - 1];
194
- new_target = target.upsample(new_source.size().width, new_source.size().height, m_pyramid[level - 1].global_mask());
215
+ new_target =
216
+ target.upsample(new_source.size().width, new_source.size().height, m_pyramid[level - 1].global_mask());
195
217
  upscaled = true;
196
218
  }
197
219
  else
@@ -203,16 +225,28 @@ MaskedImage Inpainting::_expectation_maximization(MaskedImage source, MaskedImag
203
225
  auto vote = cv::Mat(new_target.size(), CV_64FC4);
204
226
  vote.setTo(cv::Scalar::all(0));
205
227
 
228
+ // The weighted mean of all overlapping patches blurs the fill, so in the last
229
+ // iteration of the finest level each pixel takes the value of its most similar
230
+ // patch instead (similar to "Space-Time Completion of Video", which takes the
231
+ // mode of the votes). On coarser levels the mean is kept: it lays out the smooth
232
+ // structure that the finer levels refine, the best vote there makes seams.
233
+ const bool best_only = level == 0 && iter_em == nr_iters_em - 1;
234
+
206
235
  // Votes for best patch from NNF Source->Target (completeness) and Target->Source (coherence).
207
- _expectation_step(m_source2target, 1, vote, new_source, upscaled);
236
+ _expectation_step(m_source2target, true, vote, new_source, upscaled, best_only);
208
237
  if (verbose)
209
238
  std::cerr << " Expectation source to target finished." << std::endl;
210
- _expectation_step(m_target2source, 0, vote, new_source, upscaled);
239
+ _expectation_step(m_target2source, false, vote, new_source, upscaled, best_only);
211
240
  if (verbose)
212
241
  std::cerr << " Expectation target to source finished." << std::endl;
213
242
 
214
243
  // Compile votes and update pixel values.
215
- _maximization_step(new_target, vote);
244
+ // Known pixels are kept on the two finest levels only. On coarser levels those
245
+ // next to the holes average only the known part of their downsampling kernel,
246
+ // estimates that reach into the holes; keeping them there made the fills copy
247
+ // smooth regions, e.g. a concrete wall into the foliage above it.
248
+ const int new_level = upscaled ? level - 1 : level;
249
+ _maximization_step(new_target, vote, new_source, new_level <= 1);
216
250
  if (verbose)
217
251
  std::cerr << " Minimization step finished." << std::endl;
218
252
  }
@@ -222,14 +256,22 @@ MaskedImage Inpainting::_expectation_maximization(MaskedImage source, MaskedImag
222
256
 
223
257
  // Expectation step: vote for best estimations of each pixel.
224
258
  void Inpainting::_expectation_step(
225
- const NearestNeighborField &nnf, bool source2target,
226
- cv::Mat &vote, const MaskedImage &source, bool upscaled)
259
+ const NearestNeighborField &nnf, bool source2target, cv::Mat &vote, const MaskedImage &source, bool upscaled,
260
+ bool best_only)
227
261
  {
228
262
  auto source_size = nnf.source_size();
229
263
  auto target_size = nnf.target_size();
230
264
  const int patch_size = m_distance_metric->patch_size();
231
265
  const auto &kDistance2Similarity = distance2similarity();
232
266
 
267
+ double w = 0;
268
+ auto copy = [&](int ys, int xs, int yt, int xt) {
269
+ if (best_only)
270
+ _best_copy(source, ys, xs, vote, yt, xt, w);
271
+ else
272
+ _weighted_copy(source, ys, xs, vote, yt, xt, w);
273
+ };
274
+
233
275
  for (int i = 0; i < source_size.height; ++i)
234
276
  {
235
277
  for (int j = 0; j < source_size.width; ++j)
@@ -237,7 +279,7 @@ void Inpainting::_expectation_step(
237
279
  if (nnf.source().is_globally_masked(i, j))
238
280
  continue;
239
281
  int yp = nnf.at(i, j, 0), xp = nnf.at(i, j, 1), dp = nnf.at(i, j, 2);
240
- double w = kDistance2Similarity[dp];
282
+ w = kDistance2Similarity[dp];
241
283
 
242
284
  for (int di = -patch_size; di <= patch_size; ++di)
243
285
  {
@@ -265,13 +307,13 @@ void Inpainting::_expectation_step(
265
307
  {
266
308
  for (int ux = 0; ux < 2; ++ux)
267
309
  {
268
- _weighted_copy(source, 2 * ys + uy, 2 * xs + ux, vote, 2 * yt + uy, 2 * xt + ux, w);
310
+ copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux);
269
311
  }
270
312
  }
271
313
  }
272
314
  else
273
315
  {
274
- _weighted_copy(source, ys, xs, vote, yt, xt, w);
316
+ copy(ys, xs, yt, xt);
275
317
  }
276
318
  }
277
319
  }
@@ -280,7 +322,8 @@ void Inpainting::_expectation_step(
280
322
  }
281
323
 
282
324
  // Maximization Step: maximum likelihood of target pixel.
283
- void Inpainting::_maximization_step(MaskedImage &target, const cv::Mat &vote)
325
+ void Inpainting::_maximization_step(
326
+ MaskedImage &target, const cv::Mat &vote, const MaskedImage &source, bool keep_known) const
284
327
  {
285
328
  auto target_size = target.size();
286
329
  for (int i = 0; i < target_size.height; ++i)
@@ -295,6 +338,16 @@ void Inpainting::_maximization_step(MaskedImage &target, const cv::Mat &vote)
295
338
  continue;
296
339
  }
297
340
 
341
+ // Known pixels keep their values. Otherwise the votes of patches that overlap
342
+ // a hole change them, the holes are filled to match the changed surroundings,
343
+ // and a seam appears once the known pixels are restored at the end.
344
+ if (keep_known && !source.is_masked(i, j))
345
+ {
346
+ const unsigned char *known = source.get_image(i, j);
347
+ std::copy(known, known + 3, target_ptr);
348
+ continue;
349
+ }
350
+
298
351
  if (source_ptr[3] > 0)
299
352
  {
300
353
  unsigned char r = cv::saturate_cast<unsigned char>(source_ptr[0] / source_ptr[3]);
@@ -15,8 +15,10 @@ public:
15
15
  private:
16
16
  void _initialize_pyramid(void);
17
17
  MaskedImage _expectation_maximization(MaskedImage source, MaskedImage target, int level, bool verbose);
18
- void _expectation_step(const NearestNeighborField &nnf, bool source2target, cv::Mat &vote, const MaskedImage &source, bool upscaled);
19
- void _maximization_step(MaskedImage &target, const cv::Mat &vote);
18
+ void _expectation_step(
19
+ const NearestNeighborField &nnf, bool source2target, cv::Mat &vote, const MaskedImage &source, bool upscaled,
20
+ bool best_only);
21
+ void _maximization_step(MaskedImage &target, const cv::Mat &vote, const MaskedImage &source, bool keep_known) const;
20
22
 
21
23
  MaskedImage m_initial;
22
24
  std::vector<MaskedImage> m_pyramid;
@@ -1,4 +1,5 @@
1
1
  #include "masked_image.h"
2
+
2
3
  #include <algorithm>
3
4
  #include <iostream>
4
5
 
@@ -73,7 +74,9 @@ MaskedImage MaskedImage::downsample() const
73
74
  if (ksum > 0)
74
75
  {
75
76
  auto target_ptr = ret.get_mutable_image(y / 2, x / 2);
76
- target_ptr[0] = r, target_ptr[1] = g, target_ptr[2] = b;
77
+ target_ptr[0] = static_cast<unsigned char>(r);
78
+ target_ptr[1] = static_cast<unsigned char>(g);
79
+ target_ptr[2] = static_cast<unsigned char>(b);
77
80
  ret.set_mask(y / 2, x / 2, 0);
78
81
  }
79
82
  else
@@ -14,12 +14,14 @@ public:
14
14
  {
15
15
  // pass
16
16
  }
17
- MaskedImage(cv::Mat image, cv::Mat mask, cv::Mat global_mask) : m_image(image), m_mask(mask), m_global_mask(global_mask), m_image_grad_computed(false)
17
+ MaskedImage(cv::Mat image, cv::Mat mask, cv::Mat global_mask)
18
+ : m_image(image), m_mask(mask), m_global_mask(global_mask), m_image_grad_computed(false)
18
19
  {
19
20
  // pass
20
21
  }
21
- MaskedImage(cv::Mat image, cv::Mat mask, cv::Mat global_mask, cv::Mat grady, cv::Mat gradx, bool grad_computed) : m_image(image), m_mask(mask), m_global_mask(global_mask),
22
- m_image_grady(grady), m_image_gradx(gradx), m_image_grad_computed(grad_computed)
22
+ MaskedImage(cv::Mat image, cv::Mat mask, cv::Mat global_mask, cv::Mat grady, cv::Mat gradx, bool grad_computed)
23
+ : m_image(image), m_mask(mask), m_global_mask(global_mask), m_image_grady(grady), m_image_gradx(gradx),
24
+ m_image_grad_computed(grad_computed)
23
25
  {
24
26
  // pass
25
27
  }
@@ -31,11 +33,18 @@ public:
31
33
  m_mask = cv::Mat(cv::Size(width, height), CV_8U);
32
34
  m_mask = cv::Scalar::all(0);
33
35
  }
36
+ // cv::Mat's move assignment is not noexcept, so the implicit one would not be
37
+ MaskedImage(const MaskedImage &) = default;
38
+ MaskedImage(MaskedImage &&) noexcept = default;
39
+ MaskedImage &operator=(const MaskedImage &) = default;
40
+ MaskedImage &operator=(MaskedImage &&) noexcept = default;
41
+ ~MaskedImage() = default;
42
+
34
43
  inline MaskedImage clone()
35
44
  {
36
45
  return MaskedImage(
37
- m_image.clone(), m_mask.clone(), m_global_mask.clone(),
38
- m_image_grady.clone(), m_image_gradx.clone(), m_image_grad_computed);
46
+ m_image.clone(), m_mask.clone(), m_global_mask.clone(), m_image_grady.clone(), m_image_gradx.clone(),
47
+ m_image_grad_computed);
39
48
  }
40
49
 
41
50
  inline cv::Size size() const
@@ -1,14 +1,16 @@
1
+ #include "nnf.h"
2
+
1
3
  #include <algorithm>
2
- #include <iostream>
3
4
  #include <cmath>
5
+ #include <cstdint>
6
+ #include <iostream>
4
7
  #include <random>
5
8
 
6
9
  #include "masked_image.h"
7
- #include "nnf.h"
8
10
 
9
11
  /**
10
12
  * Nearest-Neighbor Field (see PatchMatch algorithm).
11
- * This algorithme uses a version proposed by Xavier Philippeau.
13
+ * This algorithm uses a version proposed by Xavier Philippeau.
12
14
  *
13
15
  */
14
16
 
@@ -36,7 +38,7 @@ namespace
36
38
  {
37
39
  return static_cast<int>(random_engine()() % static_cast<unsigned int>(n));
38
40
  }
39
- }
41
+ } // namespace
40
42
 
41
43
  void NearestNeighborField::seed_random(unsigned int seed)
42
44
  {
@@ -98,8 +100,11 @@ void NearestNeighborField::_initialize_field_from(const NearestNeighborField &ot
98
100
  auto this_value = mutable_ptr(i, j);
99
101
  auto other_value = other.ptr(ilow, jlow);
100
102
 
101
- this_value[0] = static_cast<int>(other_value[0] * fi);
102
- this_value[1] = static_cast<int>(other_value[1] * fj);
103
+ // Keep the offset within the coarse pixel, so that neighbors still point to
104
+ // neighbors. Without it, both pixels of a pair point to the same target pixel,
105
+ // which makes the upscaled fill blocky.
106
+ this_value[0] = clamp(static_cast<int>(other_value[0] * fi + (i - ilow * fi)), 0, target_size().height - 1);
107
+ this_value[1] = clamp(static_cast<int>(other_value[1] * fj + (j - jlow * fj)), 0, target_size().width - 1);
103
108
  this_value[2] = _distance(i, j, this_value[0], this_value[1]);
104
109
  }
105
110
  }
@@ -196,12 +201,11 @@ namespace
196
201
  }
197
202
 
198
203
  int distance_masked_images(
199
- const MaskedImage &source, int ys, int xs,
200
- const MaskedImage &target, int yt, int xt,
201
- int patch_size)
204
+ const MaskedImage &source, int ys, int xs, const MaskedImage &target, int yt, int xt, int patch_size)
202
205
  {
203
- long double distance = 0;
204
- long double wsum = 0;
206
+ // Exact integer sums: long double is emulated in software on Linux aarch64
207
+ std::int64_t distance = 0;
208
+ std::int64_t wsum = 0;
205
209
 
206
210
  source.compute_image_gradients();
207
211
  target.compute_image_gradients();
@@ -215,7 +219,7 @@ namespace
215
219
 
216
220
  if (yys <= 0 || yys >= source_size.height - 1 || yyt <= 0 || yyt >= target_size.height - 1)
217
221
  {
218
- distance += (long double)(PatchSSDDistanceMetric::kSSDScale) * (2 * patch_size + 1);
222
+ distance += std::int64_t{PatchSSDDistanceMetric::kSSDScale} * (2 * patch_size + 1);
219
223
  wsum += 2 * patch_size + 1;
220
224
  continue;
221
225
  }
@@ -272,22 +276,23 @@ namespace
272
276
  }
273
277
  }
274
278
 
275
- distance /= (long double)(PatchSSDDistanceMetric::kSSDScale);
276
-
277
- int res = int(PatchDistanceMetric::kDistanceScale * distance / wsum);
279
+ const double scaled = static_cast<double>(distance) / PatchSSDDistanceMetric::kSSDScale;
280
+ const auto res = static_cast<int>(PatchDistanceMetric::kDistanceScale * scaled / static_cast<double>(wsum));
278
281
  if (res < 0 || res > PatchDistanceMetric::kDistanceScale)
279
282
  return PatchDistanceMetric::kDistanceScale;
280
283
  return res;
281
284
  }
282
285
 
283
- }
286
+ } // namespace
284
287
 
285
- int PatchSSDDistanceMetric::operator()(const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const
288
+ int PatchSSDDistanceMetric::operator()(
289
+ const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const
286
290
  {
287
291
  return distance_masked_images(source, source_y, source_x, target, target_y, target_x, m_patch_size);
288
292
  }
289
293
 
290
- int RegularityGuidedPatchDistanceMetricV2::operator()(const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const
294
+ int RegularityGuidedPatchDistanceMetricV2::operator()(
295
+ const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const
291
296
  {
292
297
  if (target_y < 0 || target_y >= target.size().height || target_x < 0 || target_x >= target.size().width)
293
298
  return PatchDistanceMetric::kDistanceScale;
@@ -1,7 +1,8 @@
1
1
  #pragma once
2
2
 
3
- #include <stdexcept>
4
3
  #include <opencv2/core.hpp>
4
+ #include <stdexcept>
5
+
5
6
  #include "masked_image.h"
6
7
 
7
8
  class PatchDistanceMetric
@@ -10,8 +11,13 @@ public:
10
11
  PatchDistanceMetric(int patch_size) : m_patch_size(patch_size) {}
11
12
  virtual ~PatchDistanceMetric() = default;
12
13
 
13
- inline int patch_size() const { return m_patch_size; }
14
- virtual int operator()(const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const = 0;
14
+ inline int patch_size() const
15
+ {
16
+ return m_patch_size;
17
+ }
18
+ virtual int operator()(
19
+ const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y,
20
+ int target_x) const = 0;
15
21
  static const int kDistanceScale;
16
22
 
17
23
  protected:
@@ -25,13 +31,16 @@ public:
25
31
  {
26
32
  // pass
27
33
  }
28
- NearestNeighborField(const MaskedImage &source, const MaskedImage &target, const PatchDistanceMetric *metric, int max_retry = 20)
34
+ NearestNeighborField(
35
+ const MaskedImage &source, const MaskedImage &target, const PatchDistanceMetric *metric, int max_retry = 20)
29
36
  : m_source(source), m_target(target), m_distance_metric(metric)
30
37
  {
31
38
  m_field = cv::Mat(m_source.size(), CV_32SC3);
32
39
  _randomize_field(max_retry);
33
40
  }
34
- NearestNeighborField(const MaskedImage &source, const MaskedImage &target, const PatchDistanceMetric *metric, const NearestNeighborField &other, int max_retry = 20)
41
+ NearestNeighborField(
42
+ const MaskedImage &source, const MaskedImage &target, const PatchDistanceMetric *metric,
43
+ const NearestNeighborField &other, int max_retry = 20)
35
44
  : m_source(source), m_target(target), m_distance_metric(metric)
36
45
  {
37
46
  m_field = cv::Mat(m_source.size(), CV_32SC3);
@@ -111,7 +120,9 @@ class PatchSSDDistanceMetric : public PatchDistanceMetric
111
120
  {
112
121
  public:
113
122
  using PatchDistanceMetric::PatchDistanceMetric;
114
- virtual int operator()(const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const;
123
+ virtual int operator()(
124
+ const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y,
125
+ int target_x) const;
115
126
  static const int kSSDScale;
116
127
  };
117
128
 
@@ -125,7 +136,9 @@ public:
125
136
  if (!(weight >= 0))
126
137
  throw std::invalid_argument("the guide weight must be >= 0");
127
138
  }
128
- virtual int operator()(const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y, int target_x) const;
139
+ virtual int operator()(
140
+ const MaskedImage &source, int source_y, int source_x, const MaskedImage &target, int target_y,
141
+ int target_x) const;
129
142
 
130
143
  protected:
131
144
  cv::Mat m_ijmap;
@@ -1,10 +1,11 @@
1
+ #include "pyinterface.h"
2
+
1
3
  #include <atomic>
2
4
  #include <cstring>
3
5
  #include <exception>
4
6
  #include <memory>
5
7
  #include <string>
6
8
 
7
- #include "pyinterface.h"
8
9
  #include "inpaint.h"
9
10
 
10
11
  int _dtype_py_to_cv(int dtype_py);
@@ -66,7 +67,7 @@ namespace
66
67
  }
67
68
  return PM_mat_t{nullptr, {0, 0, 0}, 0};
68
69
  }
69
- }
70
+ } // namespace
70
71
 
71
72
  const char *PM_last_error()
72
73
  {
@@ -85,10 +86,8 @@ void PM_set_verbose(int value)
85
86
 
86
87
  void PM_free_pymat(PM_mat_t pymat)
87
88
  {
88
- // Takes back the ownership of the buffer that _cv2_to_py released. The buffer
89
- // crosses the C interface as a plain pointer, so it cannot be a std::vector
90
- // (SonarCloud cpp:S5945).
91
- std::unique_ptr<unsigned char[]> data(static_cast<unsigned char *>(pymat.data_ptr)); // NOSONAR
89
+ // Frees the buffer that _cv2_to_py released.
90
+ std::default_delete<unsigned char[]>()(static_cast<unsigned char *>(pymat.data_ptr));
92
91
  }
93
92
 
94
93
  PM_mat_t PM_inpaint(PM_mat_t source_py, PM_mat_t mask_py, int patch_size)
@@ -102,7 +101,8 @@ PM_mat_t PM_inpaint(PM_mat_t source_py, PM_mat_t mask_py, int patch_size)
102
101
  });
103
102
  }
104
103
 
105
- PM_mat_t PM_inpaint_regularity(PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t ijmap_py, int patch_size, float guide_weight)
104
+ PM_mat_t PM_inpaint_regularity(
105
+ PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t ijmap_py, int patch_size, float guide_weight)
106
106
  {
107
107
  return guarded([source_py, mask_py, ijmap_py, patch_size, guide_weight] {
108
108
  cv::Mat source = _py_to_cv2(source_py);
@@ -123,12 +123,15 @@ PM_mat_t PM_inpaint2(PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t global_mask_
123
123
  cv::Mat global_mask = _py_to_cv2(global_mask_py);
124
124
 
125
125
  auto metric = PatchSSDDistanceMetric(patch_size);
126
- cv::Mat result = Inpainting(source, mask, global_mask, &metric).run(verbose().load(), false, random_seed().load());
126
+ cv::Mat result =
127
+ Inpainting(source, mask, global_mask, &metric).run(verbose().load(), false, random_seed().load());
127
128
  return _cv2_to_py(result);
128
129
  });
129
130
  }
130
131
 
131
- PM_mat_t PM_inpaint2_regularity(PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t global_mask_py, PM_mat_t ijmap_py, int patch_size, float guide_weight)
132
+ PM_mat_t PM_inpaint2_regularity(
133
+ PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t global_mask_py, PM_mat_t ijmap_py, int patch_size,
134
+ float guide_weight)
132
135
  {
133
136
  return guarded([source_py, mask_py, global_mask_py, ijmap_py, patch_size, guide_weight] {
134
137
  cv::Mat source = _py_to_cv2(source_py);
@@ -137,7 +140,8 @@ PM_mat_t PM_inpaint2_regularity(PM_mat_t source_py, PM_mat_t mask_py, PM_mat_t g
137
140
  cv::Mat ijmap = _py_to_cv2(ijmap_py);
138
141
 
139
142
  auto metric = RegularityGuidedPatchDistanceMetricV2(patch_size, ijmap, guide_weight);
140
- cv::Mat result = Inpainting(source, mask, global_mask, &metric).run(verbose().load(), false, random_seed().load());
143
+ cv::Mat result =
144
+ Inpainting(source, mask, global_mask, &metric).run(verbose().load(), false, random_seed().load());
141
145
  return _cv2_to_py(result);
142
146
  });
143
147
  }
@@ -1,9 +1,9 @@
1
1
  #pragma once
2
2
 
3
- #include <opencv2/core.hpp>
4
- #include <cstdlib>
5
3
  #include <cstdio>
4
+ #include <cstdlib>
6
5
  #include <cstring>
6
+ #include <opencv2/core.hpp>
7
7
 
8
8
  extern "C"
9
9
  {
@@ -42,6 +42,7 @@ extern "C"
42
42
  PM_mat_t PM_inpaint(PM_mat_t image, PM_mat_t mask, int patch_size);
43
43
  PM_mat_t PM_inpaint_regularity(PM_mat_t image, PM_mat_t mask, PM_mat_t ijmap, int patch_size, float guide_weight);
44
44
  PM_mat_t PM_inpaint2(PM_mat_t image, PM_mat_t mask, PM_mat_t global_mask, int patch_size);
45
- PM_mat_t PM_inpaint2_regularity(PM_mat_t image, PM_mat_t mask, PM_mat_t global_mask, PM_mat_t ijmap, int patch_size, float guide_weight);
45
+ PM_mat_t PM_inpaint2_regularity(
46
+ PM_mat_t image, PM_mat_t mask, PM_mat_t global_mask, PM_mat_t ijmap, int patch_size, float guide_weight);
46
47
 
47
48
  } /* extern "C" */
@@ -134,8 +134,11 @@ def main() -> None:
134
134
  options.update(WINDOWS_CMAKE_OPTIONS)
135
135
  defines = [f"-D{key}={value}" for key, value in options.items()]
136
136
  subprocess.run([cmake, "-S", source, "-B", build, *defines], check=True)
137
+ # without a count, `make -j` starts all compilations at once
138
+ jobs = str(os.cpu_count() or 1)
137
139
  subprocess.run(
138
- [cmake, "--build", build, "--config", "Release", "--parallel"], check=True
140
+ [cmake, "--build", build, "--config", "Release", "--parallel", jobs],
141
+ check=True,
139
142
  )
140
143
  subprocess.run([cmake, "--install", build, "--config", "Release"], check=True)
141
144
 
@@ -0,0 +1,450 @@
1
+ """Measure how well patchmatch fills holes, to compare versions of the library.
2
+
3
+ The holes are cut into images whose content is known: forest.bmp and the images listed
4
+ in examples/images/SOURCES.md. forest_pruned.bmp and every ``*_pruned.*`` image in the
5
+ git-ignored examples/images/local/ are added as object removals without a ground
6
+ truth; their pure white pixels are the holes.
7
+
8
+ Usage:
9
+ uv run python scripts/evaluate_inpainting.py
10
+ uv run python scripts/evaluate_inpainting.py --baseline main/libpatchmatch.dylib
11
+ uv run python scripts/evaluate_inpainting.py --patch-size 15 --seeds 1 --cases brick
12
+
13
+ Besides the table, it writes examples/images/evaluation.html (git-ignored): the holes,
14
+ the fills (of --baseline too) and the originals side by side. It embeds the images of
15
+ examples/images/local/ as well, so keep it out of the repository.
16
+
17
+ The e2e tests use measure() to check the seam and the detail of forest_pruned.bmp.
18
+
19
+ Columns, means over the seeds:
20
+ seam color difference of neighbors across the border of the holes, relative to
21
+ neighbors on either side of it; about 1.0 in the original images
22
+ detail color difference of neighbors inside the holes, relative to their
23
+ surroundings; 0.85 to 1.1 in the original images, lower is blurrier
24
+ error mean color error against the original on images reduced to 1/8, which
25
+ compares the structure of the fill rather than its pixels
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import base64
32
+ import ctypes
33
+ import html
34
+ import io
35
+ import time
36
+ from collections.abc import Iterator
37
+ from datetime import datetime
38
+ from pathlib import Path
39
+ from typing import NamedTuple
40
+
41
+ import numpy as np
42
+ from PIL import Image
43
+
44
+ import patchmatch
45
+ from patchmatch import _lib, patch_match
46
+
47
+ IMAGES = Path(__file__).resolve().parents[1] / "examples" / "images"
48
+ LOCAL = IMAGES / "local"
49
+ REPORT = IMAGES / "evaluation.html"
50
+
51
+ # (center y, center x, radius y, radius x) of an elliptic hole
52
+ HOLES = {
53
+ "brick": (256, 256, 55, 55),
54
+ "grass": (256, 256, 60, 60),
55
+ "gravel": (256, 256, 60, 60),
56
+ "coffee": (260, 85, 55, 60),
57
+ "chelsea": (52, 215, 24, 40),
58
+ }
59
+ COLUMNS = ("seam", "detail", "error")
60
+
61
+
62
+ class Case(NamedTuple):
63
+ name: str
64
+ image: np.ndarray
65
+ holes: np.ndarray
66
+ truth: np.ndarray | None
67
+
68
+
69
+ class Result(NamedTuple):
70
+ values: dict[str, float] # means over the seeds
71
+ seconds: float # per seed
72
+ fill: np.ndarray # of seed 0
73
+
74
+
75
+ def load(path: Path) -> np.ndarray:
76
+ return np.array(Image.open(path).convert("RGB"))
77
+
78
+
79
+ def white(image: np.ndarray) -> np.ndarray:
80
+ return (image == 255).all(axis=2)
81
+
82
+
83
+ def ellipse(shape: tuple[int, ...], cy: int, cx: int, ry: int, rx: int) -> np.ndarray:
84
+ yy, xx = np.mgrid[: shape[0], : shape[1]]
85
+ return ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 < 1
86
+
87
+
88
+ def cut(name: str, truth: np.ndarray, holes: np.ndarray) -> Case:
89
+ image = truth.copy()
90
+ image[holes] = 255
91
+ return Case(name, image, holes, truth)
92
+
93
+
94
+ def cases() -> Iterator[Case]:
95
+ forest = load(IMAGES / "forest.bmp")
96
+ pruned = load(IMAGES / "forest_pruned.bmp")
97
+ yield Case("forest", pruned, white(pruned), None)
98
+ yield cut("trees", forest, ellipse(forest.shape, 70, 560, 45, 55))
99
+ meadow = np.zeros(forest.shape[:2], dtype=bool)
100
+ meadow[318:350, 380:470] = True
101
+ yield cut("meadow", forest, meadow)
102
+ for name, hole in HOLES.items():
103
+ truth = load(IMAGES / f"{name}.png")
104
+ yield cut(name, truth, ellipse(truth.shape, *hole))
105
+ for path in sorted(LOCAL.glob("*_pruned.*")):
106
+ image = load(path)
107
+ yield Case(path.stem.removesuffix("_pruned"), image, white(image), None)
108
+
109
+
110
+ def grow(mask: np.ndarray, radius: int) -> np.ndarray:
111
+ grown = mask.copy()
112
+ for _ in range(radius):
113
+ step = grown.copy()
114
+ step[1:] |= grown[:-1]
115
+ step[:-1] |= grown[1:]
116
+ step[:, 1:] |= grown[:, :-1]
117
+ step[:, :-1] |= grown[:, 1:]
118
+ grown = step
119
+ return grown
120
+
121
+
122
+ def neighbor_difference(
123
+ image: np.ndarray, first: np.ndarray, second: np.ndarray
124
+ ) -> float:
125
+ """Mean color difference of adjacent pixels, one in ``first``, one in ``second``."""
126
+ pixels = image.astype(int)
127
+ dx = np.abs(pixels[:, 1:] - pixels[:, :-1]).sum(axis=2)
128
+ dy = np.abs(pixels[1:] - pixels[:-1]).sum(axis=2)
129
+ pairs_x = (first[:, 1:] & second[:, :-1]) | (second[:, 1:] & first[:, :-1])
130
+ pairs_y = (first[1:] & second[:-1]) | (second[1:] & first[:-1])
131
+ return float(np.concatenate([dx[pairs_x], dy[pairs_y]]).mean())
132
+
133
+
134
+ def coarse_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
135
+ def reduce(array: np.ndarray) -> np.ndarray:
136
+ return np.array(Image.fromarray(array).reduce(8)).astype(float)
137
+
138
+ cells = reduce(holes.astype(np.uint8) * 255) > 200
139
+ return float(np.abs(reduce(image)[cells] - reduce(truth)[cells]).mean())
140
+
141
+
142
+ def measure(
143
+ result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
144
+ ) -> dict[str, float]:
145
+ """The columns of the table for the fill ``result`` of ``holes``."""
146
+ known = ~holes
147
+ inner_ring = holes & grow(known, 8)
148
+ outer_ring = known & grow(holes, 8)
149
+ inside = holes & ~grow(known, 2)
150
+ around = grow(holes, 24) & ~grow(holes, 2)
151
+ along = (
152
+ neighbor_difference(result, inner_ring, inner_ring)
153
+ + neighbor_difference(result, outer_ring, outer_ring)
154
+ ) / 2
155
+ values = {
156
+ "seam": neighbor_difference(result, holes, known) / along,
157
+ "detail": neighbor_difference(result, inside, inside)
158
+ / neighbor_difference(result, around, around),
159
+ }
160
+ if truth is not None:
161
+ values["error"] = coarse_error(result, truth, holes)
162
+ return values
163
+
164
+
165
+ def load_library(path: Path) -> ctypes.CDLL:
166
+ """Load another build of the library than the installed one."""
167
+ _lib.find_library = lambda: path
168
+ return _lib.load_library()
169
+
170
+
171
+ def evaluate(library: ctypes.CDLL, case: Case, patch_size: int, seeds: int) -> Result:
172
+ patch_match._lib = library
173
+ mask = case.holes.astype(np.uint8)
174
+ rows, fills = [], []
175
+ start = time.perf_counter()
176
+ for seed in range(seeds):
177
+ patchmatch.set_random_seed(seed)
178
+ fills.append(patchmatch.inpaint(case.image, mask, patch_size=patch_size))
179
+ rows.append(measure(fills[-1], case.holes, case.truth))
180
+ seconds = (time.perf_counter() - start) / seeds
181
+ values = {key: float(np.mean([row[key] for row in rows])) for key in rows[0]}
182
+ return Result(values, seconds, fills[0])
183
+
184
+
185
+ def print_row(name: str, results: list[Result]) -> None:
186
+ """One line of the table, with ``old -> new`` for a baseline."""
187
+ cells = []
188
+ for key in COLUMNS:
189
+ found = [
190
+ f"{result.values[key]:.2f}" for result in results if key in result.values
191
+ ]
192
+ cells.append(f"{' -> '.join(found):16}")
193
+ seconds = " -> ".join(f"{result.seconds:.1f}s" for result in results)
194
+ print(f"{name:10} {''.join(cells)}{seconds}", flush=True)
195
+
196
+
197
+ # Report ------------------------------------------------------------------------------
198
+
199
+ STYLE = """
200
+ :root { color-scheme: light dark; --bg: #f7f7f5; --fg: #1d1d1b; --muted: #6f6f6a;
201
+ --card: #fff; --line: #e3e3de; --good: #1b7f3b; --bad: #b42318; }
202
+ @media (prefers-color-scheme: dark) { :root { --bg: #161615; --fg: #efefec;
203
+ --muted: #a3a39c; --card: #22221f; --line: #3a3a36; --good: #5bd181;
204
+ --bad: #ff8a7a; } }
205
+ * { box-sizing: border-box; }
206
+ body { margin: 0; padding: 0 16px 64px; background: var(--bg); color: var(--fg);
207
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
208
+ main { max-width: 1440px; margin: 0 auto; }
209
+ header { padding: 24px 0 8px; }
210
+ h1 { font-size: 22px; margin: 0 0 4px; }
211
+ .meta, .hint { color: var(--muted); font-size: 13px; margin: 0; }
212
+ .controls { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap;
213
+ gap: 8px 24px; padding: 10px 0; background: var(--bg);
214
+ border-bottom: 1px solid var(--line); }
215
+ .controls label { cursor: pointer; }
216
+ table { border-collapse: collapse; margin: 16px 0 28px;
217
+ font-variant-numeric: tabular-nums; }
218
+ th, td { padding: 4px 18px 4px 0; text-align: right; white-space: nowrap; }
219
+ th:first-child, td:first-child { text-align: left; }
220
+ th { color: var(--muted); font-weight: 500; border-bottom: 1px solid var(--line); }
221
+ a { color: inherit; }
222
+ .better { color: var(--good); font-weight: 600; }
223
+ .worse { color: var(--bad); font-weight: 600; }
224
+ section { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
225
+ padding: 12px 16px 16px; margin: 0 0 20px; }
226
+ section h2 { font-size: 17px; margin: 0; display: inline; }
227
+ section .meta { display: inline; margin-left: 10px; }
228
+ .versions { display: grid; gap: 12px; margin-top: 10px;
229
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
230
+ figure { margin: 0; cursor: zoom-in; }
231
+ figcaption { font-size: 13px; color: var(--muted); margin-top: 4px;
232
+ font-variant-numeric: tabular-nums; }
233
+ figcaption b { color: var(--fg); font-weight: 600; margin-right: 6px; }
234
+ .frame { position: relative; overflow: hidden; width: 100%;
235
+ aspect-ratio: var(--crop); background: var(--line); }
236
+ .frame img { position: absolute; display: block; max-width: none; height: auto;
237
+ left: var(--left); top: var(--top); width: var(--width); }
238
+ body.whole .frame { aspect-ratio: var(--full); }
239
+ body.whole .frame img { left: 0; top: 0; width: 100%; }
240
+ body.pixels img { image-rendering: pixelated; }
241
+ #viewer { position: fixed; inset: 0; z-index: 10; display: flex;
242
+ flex-direction: column; align-items: center; justify-content: center; gap: 10px;
243
+ background: rgb(0 0 0 / 0.9); cursor: zoom-out; }
244
+ #viewer[hidden] { display: none; }
245
+ #viewer .frame { width: min(94vw, calc(86vh * var(--crop))); cursor: pointer; }
246
+ body.whole #viewer .frame { width: min(94vw, calc(86vh * var(--full))); }
247
+ #viewer p { color: #ddd; font-size: 14px; margin: 0; }
248
+ """
249
+
250
+ SCRIPT = """
251
+ const viewer = document.getElementById("viewer");
252
+ let figures = [];
253
+ let index = 0;
254
+ function show(i) {
255
+ index = (i + figures.length) % figures.length;
256
+ const figure = figures[index];
257
+ viewer.querySelector(".slot").replaceChildren(
258
+ figure.querySelector(".frame").cloneNode(true));
259
+ viewer.querySelector("p").textContent = figure.dataset.title
260
+ + " (" + (index + 1) + "/" + figures.length + ")";
261
+ }
262
+ for (const figure of document.querySelectorAll("figure")) {
263
+ figure.addEventListener("click", () => {
264
+ figures = Array.from(figure.parentElement.querySelectorAll("figure"));
265
+ viewer.hidden = false;
266
+ show(figures.indexOf(figure));
267
+ });
268
+ }
269
+ viewer.addEventListener("click", (event) => {
270
+ if (event.target.closest(".frame")) show(index + 1);
271
+ else viewer.hidden = true;
272
+ });
273
+ document.addEventListener("keydown", (event) => {
274
+ if (viewer.hidden) return;
275
+ if (event.key === "ArrowRight" || event.key === " ") show(index + 1);
276
+ else if (event.key === "ArrowLeft") show(index - 1);
277
+ else if (event.key === "Escape") viewer.hidden = true;
278
+ else return;
279
+ event.preventDefault();
280
+ });
281
+ for (const name of ["whole", "pixels"]) {
282
+ const box = document.getElementById(name);
283
+ const apply = () => document.body.classList.toggle(name, box.checked);
284
+ box.addEventListener("change", apply);
285
+ apply(); // browsers restore the checkboxes on reload
286
+ }
287
+ """
288
+
289
+
290
+ def png(image: np.ndarray) -> str:
291
+ buffer = io.BytesIO()
292
+ Image.fromarray(image).save(buffer, format="PNG")
293
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
294
+
295
+
296
+ def crop_box(holes: np.ndarray) -> tuple[int, int, int, int]:
297
+ """(x, y, width, height) around the holes, or the whole image if they fill most."""
298
+ height, width = holes.shape
299
+ if not holes.any():
300
+ return 0, 0, width, height
301
+ ys, xs = np.nonzero(holes)
302
+ margin = max(16, (max(np.ptp(ys), np.ptp(xs)) + 1) // 4)
303
+ x0, x1 = max(0, xs.min() - margin), min(width, xs.max() + 1 + margin)
304
+ y0, y1 = max(0, ys.min() - margin), min(height, ys.max() + 1 + margin)
305
+ if (x1 - x0) * (y1 - y0) > 0.6 * width * height:
306
+ return 0, 0, width, height
307
+ return int(x0), int(y0), int(x1 - x0), int(y1 - y0)
308
+
309
+
310
+ def frame(image: np.ndarray, box: tuple[int, int, int, int]) -> str:
311
+ """The image, cut to ``box`` by CSS unless the whole images are shown."""
312
+ height, width = image.shape[:2]
313
+ x, y, w, h = box
314
+ style = (
315
+ f"--crop:{w / h:.5f};--full:{width / height:.5f};--width:{100 * width / w:.4f}%;"
316
+ f"--left:{-100 * x / w:.4f}%;--top:{-100 * y / h:.4f}%"
317
+ )
318
+ source = f"data:image/png;base64,{png(image)}"
319
+ return f'<div class="frame" style="{style}"><img alt="" src="{source}"></div>'
320
+
321
+
322
+ def verdict(key: str, old: float, new: float) -> str:
323
+ """CSS class of ``new`` compared with ``old``: better, worse or none."""
324
+ if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
325
+ return ""
326
+ if key in ("seam", "detail"): # best at 1.0, as in the original images
327
+ old, new = abs(old - 1), abs(new - 1)
328
+ return "better" if new < old else "worse"
329
+
330
+
331
+ def cell(key: str, results: list[Result]) -> str:
332
+ if key == "time":
333
+ values = [result.seconds for result in results]
334
+ text = [f"{value:.1f} s" for value in values]
335
+ else:
336
+ values = [result.values[key] for result in results if key in result.values]
337
+ text = [f"{value:.2f}" for value in values]
338
+ if not values:
339
+ return "<td></td>"
340
+ if len(values) == 1:
341
+ return f"<td>{text[0]}</td>"
342
+ css = verdict(key, values[0], values[1])
343
+ return f'<td>{text[0]} &rarr; <span class="{css}">{text[1]}</span></td>'
344
+
345
+
346
+ def caption(label: str, result: Result | None) -> str:
347
+ parts = [f"<b>{label}</b>"]
348
+ if result is not None:
349
+ parts += [f"{key} {value:.2f}" for key, value in result.values.items()]
350
+ return " ".join(parts)
351
+
352
+
353
+ def section(case: Case, results: list[Result], labels: list[str]) -> str:
354
+ box = crop_box(case.holes)
355
+ versions = [("holes", case.image, None)]
356
+ versions += [(label, r.fill, r) for label, r in zip(labels, results, strict=True)]
357
+ if case.truth is not None:
358
+ versions.append(("original", case.truth, None))
359
+ figures = "".join(
360
+ f'<figure data-title="{html.escape(case.name)}: {label}">'
361
+ f"{frame(image, box)}<figcaption>{caption(label, result)}</figcaption></figure>"
362
+ for label, image, result in versions
363
+ )
364
+ height, width = case.holes.shape
365
+ meta = f"{width}&times;{height}, holes {case.holes.mean():.1%}"
366
+ name = html.escape(case.name)
367
+ return (
368
+ f'<section id="{name}"><h2>{name}</h2><p class="meta">{meta}</p>'
369
+ f'<div class="versions">{figures}</div></section>'
370
+ )
371
+
372
+
373
+ def write_report(
374
+ rows: list[tuple[Case, list[Result]]], labels: list[str], meta: str
375
+ ) -> None:
376
+ columns = [*COLUMNS, "time"]
377
+ head = "".join(f"<th>{key}</th>" for key in ["case", *columns])
378
+ body = "".join(
379
+ f'<tr><td><a href="#{html.escape(case.name)}">{html.escape(case.name)}</a></td>'
380
+ + "".join(cell(key, results) for key in columns)
381
+ + "</tr>"
382
+ for case, results in rows
383
+ )
384
+ sections = "".join(section(case, results, labels) for case, results in rows)
385
+ with REPORT.open("w", encoding="utf-8") as report:
386
+ report.write(
387
+ "<!doctype html>\n"
388
+ '<html lang="en"><head><meta charset="utf-8">'
389
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
390
+ f"<title>patchmatch evaluation</title><style>{STYLE}</style></head><body>"
391
+ f"<main><header><h1>patchmatch evaluation</h1>{meta}</header>"
392
+ '<div class="controls">'
393
+ '<label><input type="checkbox" id="whole"> whole images</label>'
394
+ '<label><input type="checkbox" id="pixels"> show pixels</label>'
395
+ '<p class="hint">Click an image to enlarge it; click it, &larr; or &rarr; '
396
+ "switches between the versions, Esc closes.</p></div>"
397
+ f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
398
+ f'{sections}</main><div id="viewer" hidden><div class="slot"></div><p></p>'
399
+ f"</div><script>{SCRIPT}</script></body></html>\n"
400
+ )
401
+
402
+
403
+ def describe(args: argparse.Namespace, labels: list[str], paths: list[str]) -> str:
404
+ lines = [
405
+ f"{datetime.now():%Y-%m-%d %H:%M}, patch_size {args.patch_size}, "
406
+ f"{args.seeds} seed(s), fills of seed 0",
407
+ *(f"{label}: {path}" for label, path in zip(labels, paths, strict=True)),
408
+ ]
409
+ return "".join(f'<p class="meta">{html.escape(line)}</p>' for line in lines)
410
+
411
+
412
+ def main() -> None:
413
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
414
+ parser.add_argument("--library", type=Path, help="library file to load instead")
415
+ parser.add_argument("--baseline", type=Path, help="library file to compare with")
416
+ parser.add_argument("--patch-size", type=int, default=3)
417
+ parser.add_argument("--seeds", type=int, default=3)
418
+ parser.add_argument("--cases", help="comma-separated names, default: all")
419
+ args = parser.parse_args()
420
+ if args.seeds < 1:
421
+ parser.error("--seeds must be at least 1")
422
+
423
+ current = load_library(args.library.resolve()) if args.library else patch_match._lib
424
+ if current is None:
425
+ parser.error("the installed package has no native library, pass --library")
426
+ libraries, labels = [current], ["fill"]
427
+ if args.baseline:
428
+ libraries.insert(0, load_library(args.baseline.resolve()))
429
+ labels = ["baseline", "current"]
430
+ selected = set(args.cases.split(",")) if args.cases else None
431
+
432
+ print(f"patch_size={args.patch_size} seeds={args.seeds}")
433
+ print(f"{'case':10} {'seam':16}{'detail':16}{'error':16}time")
434
+ rows = []
435
+ for case in cases():
436
+ if selected and case.name not in selected:
437
+ continue
438
+ results = [
439
+ evaluate(lib, case, args.patch_size, args.seeds) for lib in libraries
440
+ ]
441
+ print_row(case.name, results)
442
+ rows.append((case, results))
443
+
444
+ meta = describe(args, labels, [lib._name for lib in libraries])
445
+ write_report(rows, labels, meta)
446
+ print(f"report: {REPORT}")
447
+
448
+
449
+ if __name__ == "__main__":
450
+ main()
@@ -1,5 +1,6 @@
1
1
  """End-to-end tests of the user workflows shown in examples/ and the README."""
2
2
 
3
+ import importlib.util
3
4
  from pathlib import Path
4
5
 
5
6
  import numpy as np
@@ -9,6 +10,7 @@ from PIL import Image, ImageFilter
9
10
  import patchmatch
10
11
 
11
12
  IMAGES = Path(__file__).parents[1] / "examples" / "images"
13
+ SCRIPTS = Path(__file__).parents[1] / "scripts"
12
14
 
13
15
  pytestmark = pytest.mark.e2e
14
16
 
@@ -50,6 +52,18 @@ def assert_plausible_fill(
50
52
  assert color_diff.max() < 10, color_diff
51
53
 
52
54
 
55
+ def load_evaluation():
56
+ """scripts/evaluate_inpainting.py, whose measures the quality tests share."""
57
+ path = SCRIPTS / "evaluate_inpainting.py"
58
+ spec = importlib.util.spec_from_file_location("evaluate_inpainting", path)
59
+ module = importlib.util.module_from_spec(spec)
60
+ spec.loader.exec_module(module)
61
+ return module
62
+
63
+
64
+ evaluation = load_evaluation()
65
+
66
+
53
67
  def coarse_error(image: np.ndarray, truth: np.ndarray, mask: np.ndarray) -> float:
54
68
  """Mean error inside ``mask`` on a downscaled image (texture, not pixels)."""
55
69
 
@@ -118,6 +132,30 @@ def test_global_mask_is_not_used_as_source(pruned_image):
118
132
  np.testing.assert_array_equal(result[excluded], source[excluded])
119
133
 
120
134
 
135
+ def test_fill_is_as_detailed_as_its_surroundings(pruned_image):
136
+ """The weighted mean of all overlapping patches blurred the fill (detail 0.69)."""
137
+ source = np.array(pruned_image)
138
+ holes = white_pixels(source)
139
+
140
+ result = patchmatch.inpaint(source, patch_size=3)
141
+
142
+ assert evaluation.measure(result, holes)["detail"] > 0.8
143
+
144
+
145
+ def test_no_seam_at_the_hole_border(pruned_image):
146
+ """Known pixels changed during the iterations and restored at the end left a seam.
147
+
148
+ The difference across the border of the holes was about twice the difference of
149
+ neighbors on either side of it (seam 2.03).
150
+ """
151
+ source = np.array(pruned_image)
152
+ holes = white_pixels(source)
153
+
154
+ result = patchmatch.inpaint(source, patch_size=3)
155
+
156
+ assert evaluation.measure(result, holes)["seam"] < 1.9
157
+
158
+
121
159
  @pytest.mark.parametrize("hole_value", [1, 255])
122
160
  def test_explicit_mask_matches_implicit_white_mask(pruned_image, hole_value):
123
161
  """README: an explicit mask behaves like the default mask of white pixels."""
File without changes