PyPatchMatch 2.2.0b1__tar.gz → 2.2.0b3__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 (31) hide show
  1. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/CLAUDE.md +3 -0
  2. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/PKG-INFO +15 -4
  3. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/README.md +13 -2
  4. pypatchmatch-2.2.0b3/examples/images/camera_damaged.png +0 -0
  5. pypatchmatch-2.2.0b3/examples/images/camera_damaged_mask.png +0 -0
  6. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/_version.py +3 -3
  7. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/inpaint.cpp +266 -21
  8. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/inpaint.h +3 -1
  9. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/masked_image.cpp +13 -9
  10. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/masked_image.h +39 -13
  11. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/nnf.cpp +23 -35
  12. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/pyproject.toml +4 -1
  13. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/evaluate_inpainting.py +55 -45
  14. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_e2e.py +73 -13
  15. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/CMakeLists.txt +0 -0
  16. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/LICENSE +0 -0
  17. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/SECURITY.md +0 -0
  18. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/examples/images/chelsea.png +0 -0
  19. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/examples/images/forest_pruned.bmp +0 -0
  20. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/__init__.py +0 -0
  21. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/_lib.py +0 -0
  22. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/nnf.h +0 -0
  23. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/pyinterface.cpp +0 -0
  24. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/pyinterface.h +0 -0
  25. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/patch_match.py +0 -0
  26. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/py.typed +0 -0
  27. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/build_opencv.py +0 -0
  28. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/render_readme_example.py +0 -0
  29. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/conftest.py +0 -0
  30. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_patch_match.py +0 -0
  31. {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_properties.py +0 -0
@@ -62,6 +62,9 @@ Other entry points:
62
62
  - `uv run python scripts/render_readme_example.py` runs `py_example.py` and renders the
63
63
  picture at the top of the README, `examples/images/readme_example.jpg`; rerun it and
64
64
  commit the picture after changes that affect the fills.
65
+ - `uv run --group gui gui/app.py` starts the Gradio GUI (paint holes, compare the fill).
66
+ No CI job installs gradio, so after API changes run its test with
67
+ `uv run --group gui pytest gui --no-cov` and try the GUI by hand.
65
68
  - CMake options: the `option()` lines of `CMakeLists.txt`. `PATCHMATCH_SANITIZE` turns
66
69
  off fast math, which lets the compiler drop checks.
67
70
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.2.0b1
3
+ Version: 2.2.0b3
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>
@@ -41,9 +41,12 @@ Description-Content-Type: text/markdown
41
41
  This library implements the PatchMatch based inpainting algorithm. It provides both C++
42
42
  and Python interfaces. This implementation is heavily based on the implementation by
43
43
  Younesse ANDAM: [younesse-cv/PatchMatch](https://github.com/younesse-cv/PatchMatch),
44
- with some bug fixes, and updates.
44
+ with some bug fixes, and updates. This repository continues
45
+ [vacancy/PyPatchMatch](https://github.com/vacancy/PyPatchMatch) by Jiayuan Mao, through
46
+ the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
47
+ [invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
45
48
 
46
- ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.2.0b1/examples/images/readme_example.jpg)
49
+ ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.2.0b3/examples/images/readme_example.jpg)
47
50
 
48
51
  From left to right: the photo, the input of
49
52
  [examples/py_example.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example.py)
@@ -100,7 +103,9 @@ The previous import path `from patchmatch import patch_match` keeps working.
100
103
  pixels in each direction. Larger patches follow larger structures but are much slower;
101
104
  the default of 15 compares 31x31 patches, the examples use 3. Below 5, the coarse levels
102
105
  that lay out the fill are also tried with a radius of 5, which is kept where it fits
103
- the surroundings better, so that repeating patterns continue across larger holes.
106
+ the surroundings better, so that repeating patterns continue across larger holes. Above
107
+ 7, coarse levels narrower than six patches use the largest radius that fits, but at
108
+ least 7: larger patches found no sources there and left the fill flat.
104
109
 
105
110
  `patchmatch.set_random_seed(seed)` sets the seed of the randomized search, modulo
106
111
  `2**32`, and `patchmatch.set_verbose(True)` prints the progress of the native code to
@@ -138,8 +143,14 @@ uv run pre-commit install # enable ruff and the other hooks on commit
138
143
  uv run pytest # run the test suite
139
144
  uv run pytest -m "not e2e" # only the fast unit tests
140
145
  uv run python scripts/evaluate_inpainting.py # measure the fill quality
146
+ uv run --group gui gui/app.py # draw masks in the browser, http://127.0.0.1:7860
141
147
  ```
142
148
 
149
+ The GUI tries new images quickly, without writing a script for each one, e.g. to pick
150
+ new examples or to verify edge cases: load an image, paint the holes (every painted
151
+ pixel is one) and compare the original with the fill. Its test runs with
152
+ `uv run --group gui pytest gui --no-cov`.
153
+
143
154
  The evaluation cuts holes into the images of `examples/images`, compares the fills with
144
155
  the original content and writes `examples/images/evaluation.html`, which shows them side
145
156
  by side. `--baseline` evaluates another build of the library as well, e.g. of the main
@@ -11,7 +11,10 @@
11
11
  This library implements the PatchMatch based inpainting algorithm. It provides both C++
12
12
  and Python interfaces. This implementation is heavily based on the implementation by
13
13
  Younesse ANDAM: [younesse-cv/PatchMatch](https://github.com/younesse-cv/PatchMatch),
14
- with some bug fixes, and updates.
14
+ with some bug fixes, and updates. This repository continues
15
+ [vacancy/PyPatchMatch](https://github.com/vacancy/PyPatchMatch) by Jiayuan Mao, through
16
+ the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
17
+ [invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
15
18
 
16
19
  ![Birdhouses removed from a photo by patchmatch.inpaint](examples/images/readme_example.jpg)
17
20
 
@@ -70,7 +73,9 @@ The previous import path `from patchmatch import patch_match` keeps working.
70
73
  pixels in each direction. Larger patches follow larger structures but are much slower;
71
74
  the default of 15 compares 31x31 patches, the examples use 3. Below 5, the coarse levels
72
75
  that lay out the fill are also tried with a radius of 5, which is kept where it fits
73
- the surroundings better, so that repeating patterns continue across larger holes.
76
+ the surroundings better, so that repeating patterns continue across larger holes. Above
77
+ 7, coarse levels narrower than six patches use the largest radius that fits, but at
78
+ least 7: larger patches found no sources there and left the fill flat.
74
79
 
75
80
  `patchmatch.set_random_seed(seed)` sets the seed of the randomized search, modulo
76
81
  `2**32`, and `patchmatch.set_verbose(True)` prints the progress of the native code to
@@ -108,8 +113,14 @@ uv run pre-commit install # enable ruff and the other hooks on commit
108
113
  uv run pytest # run the test suite
109
114
  uv run pytest -m "not e2e" # only the fast unit tests
110
115
  uv run python scripts/evaluate_inpainting.py # measure the fill quality
116
+ uv run --group gui gui/app.py # draw masks in the browser, http://127.0.0.1:7860
111
117
  ```
112
118
 
119
+ The GUI tries new images quickly, without writing a script for each one, e.g. to pick
120
+ new examples or to verify edge cases: load an image, paint the holes (every painted
121
+ pixel is one) and compare the original with the fill. Its test runs with
122
+ `uv run --group gui pytest gui --no-cov`.
123
+
113
124
  The evaluation cuts holes into the images of `examples/images`, compares the fills with
114
125
  the original content and writes `examples/images/evaluation.html`, which shows them side
115
126
  by side. `--baseline` evaluates another build of the library as well, e.g. of the main
@@ -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.0b1'
22
- __version_tuple__ = version_tuple = (2, 2, 0, 'b1')
21
+ __version__ = version = '2.2.0b3'
22
+ __version_tuple__ = version_tuple = (2, 2, 0, 'b3')
23
23
 
24
- __commit_id__ = commit_id = 'gb66a4a1d8'
24
+ __commit_id__ = commit_id = 'gc0f3bbfe6'
@@ -92,10 +92,159 @@ namespace
92
92
  return false;
93
93
  }
94
94
 
95
+ // The gradient magnitude at a known pixel, summed over the channels, or -1 if it or a neighbor is unknown.
96
+ double gradient_magnitude(const MaskedImage &image, int y, int x)
97
+ {
98
+ const auto size = image.size();
99
+ const int up = std::max(y - 1, 0);
100
+ const int down = std::min(y + 1, size.height - 1);
101
+ const int left = std::max(x - 1, 0);
102
+ const int right = std::min(x + 1, size.width - 1);
103
+ if (!is_known(image, y, x) || !is_known(image, up, x) || !is_known(image, down, x) ||
104
+ !is_known(image, y, left) || !is_known(image, y, right))
105
+ return -1;
106
+ int magnitude = 0;
107
+ for (int c = 0; c < 3; ++c)
108
+ magnitude += std::abs(image.get_image(down, x)[c] - image.get_image(up, x)[c]) +
109
+ std::abs(image.get_image(y, right)[c] - image.get_image(y, left)[c]);
110
+ return magnitude / 2.0;
111
+ }
112
+
113
+ // The mean gradient magnitude around each known pixel, -1 elsewhere or without one. Gradients
114
+ // across unknown pixels are left out, so the colors under the holes do not matter.
115
+ cv::Mat texture_energy(const MaskedImage &image)
116
+ {
117
+ constexpr int kRadius = 2;
118
+ const auto size = image.size();
119
+ // above and left of each pixel: the sum of the magnitudes and their number
120
+ cv::Mat sums(size.height + 1, size.width + 1, CV_64FC2, cv::Scalar::all(0));
121
+ for (int y = 0; y < size.height; ++y)
122
+ {
123
+ for (int x = 0; x < size.width; ++x)
124
+ {
125
+ const double magnitude = gradient_magnitude(image, y, x);
126
+ const cv::Vec2d measured = magnitude >= 0 ? cv::Vec2d(magnitude, 1) : cv::Vec2d();
127
+ sums.at<cv::Vec2d>(y + 1, x + 1) =
128
+ measured + sums.at<cv::Vec2d>(y, x + 1) + sums.at<cv::Vec2d>(y + 1, x) - sums.at<cv::Vec2d>(y, x);
129
+ }
130
+ }
131
+
132
+ cv::Mat energy(size, CV_64F, cv::Scalar(-1));
133
+ for (int y = 0; y < size.height; ++y)
134
+ {
135
+ const int top = std::max(y - kRadius, 0);
136
+ const int bottom = std::min(y + kRadius + 1, size.height);
137
+ for (int x = 0; x < size.width; ++x)
138
+ {
139
+ const int left = std::max(x - kRadius, 0);
140
+ const int right = std::min(x + kRadius + 1, size.width);
141
+ const cv::Vec2d window = sums.at<cv::Vec2d>(bottom, right) - sums.at<cv::Vec2d>(top, right) -
142
+ sums.at<cv::Vec2d>(bottom, left) + sums.at<cv::Vec2d>(top, left);
143
+ if (is_known(image, y, x) && window[1] > 0)
144
+ energy.at<double>(y, x) = window[0] / window[1];
145
+ }
146
+ }
147
+ return energy;
148
+ }
149
+
150
+ // The mean of the values >= 0 in the 2x2 block from (y, x), -1 if there is none.
151
+ double block_mean(const cv::Mat &values, int y, int x)
152
+ {
153
+ double sum = 0;
154
+ int count = 0;
155
+ for (int yy = y; yy < std::min(y + 2, values.rows); ++yy)
156
+ {
157
+ for (int xx = x; xx < std::min(x + 2, values.cols); ++xx)
158
+ {
159
+ if (values.at<double>(yy, xx) >= 0)
160
+ {
161
+ sum += values.at<double>(yy, xx);
162
+ ++count;
163
+ }
164
+ }
165
+ }
166
+ return count > 0 ? sum / count : -1;
167
+ }
168
+
169
+ // The value of coarse, which has half the resolution, at the center of pixel (y, x), bilinear.
170
+ double interpolate(const cv::Mat &coarse, int y, int x)
171
+ {
172
+ const double cy = std::clamp((y + 0.5) / 2 - 0.5, 0.0, coarse.rows - 1.0);
173
+ const double cx = std::clamp((x + 0.5) / 2 - 0.5, 0.0, coarse.cols - 1.0);
174
+ const auto y0 = static_cast<int>(cy);
175
+ const auto x0 = static_cast<int>(cx);
176
+ const int y1 = std::min(y0 + 1, coarse.rows - 1);
177
+ const int x1 = std::min(x0 + 1, coarse.cols - 1);
178
+ const double fy = cy - y0;
179
+ const double fx = cx - x0;
180
+ return (1 - fy) * ((1 - fx) * coarse.at<double>(y0, x0) + fx * coarse.at<double>(y0, x1)) +
181
+ fy * ((1 - fx) * coarse.at<double>(y1, x0) + fx * coarse.at<double>(y1, x1));
182
+ }
183
+
184
+ // Not std::all_of: libstdc++ subtracts the iterators, which divides by zero for an empty cv::Mat.
185
+ bool has_unknown(const cv::Mat &values)
186
+ {
187
+ for (int y = 0; y < values.rows; ++y)
188
+ for (int x = 0; x < values.cols; ++x)
189
+ if (values.at<double>(y, x) < 0)
190
+ return true;
191
+ return false;
192
+ }
193
+
194
+ // Replaces the values below 0 from a pyramid of the others (pull-push), which carries the
195
+ // values around a hole smoothly into it.
196
+ void pull_push(cv::Mat &values)
197
+ {
198
+ if (!has_unknown(values))
199
+ return;
200
+ if (values.total() == 1)
201
+ {
202
+ values.at<double>(0, 0) = 0; // nothing is known
203
+ return;
204
+ }
205
+ cv::Mat coarse((values.rows + 1) / 2, (values.cols + 1) / 2, CV_64F);
206
+ for (int y = 0; y < coarse.rows; ++y)
207
+ for (int x = 0; x < coarse.cols; ++x)
208
+ coarse.at<double>(y, x) = block_mean(values, 2 * y, 2 * x);
209
+ pull_push(coarse);
210
+ for (int y = 0; y < values.rows; ++y)
211
+ {
212
+ for (int x = 0; x < values.cols; ++x)
213
+ {
214
+ if (values.at<double>(y, x) < 0)
215
+ values.at<double>(y, x) = interpolate(coarse, y, x);
216
+ }
217
+ }
218
+ }
219
+
220
+ // The texture of the next level: the means of the 2x2 blocks.
221
+ cv::Mat halve(const cv::Mat &values, cv::Size size)
222
+ {
223
+ cv::Mat half(size, CV_64F);
224
+ for (int y = 0; y < size.height; ++y)
225
+ for (int x = 0; x < size.width; ++x)
226
+ half.at<double>(y, x) = block_mean(values, 2 * y, 2 * x);
227
+ return half;
228
+ }
229
+
230
+ cv::Mat to_bytes(const cv::Mat &values)
231
+ {
232
+ cv::Mat bytes(values.size(), CV_8U);
233
+ for (int y = 0; y < values.rows; ++y)
234
+ for (int x = 0; x < values.cols; ++x)
235
+ bytes.at<unsigned char>(y, x) = cv::saturate_cast<unsigned char>(values.at<double>(y, x));
236
+ return bytes;
237
+ }
238
+
95
239
  // A patch smaller than a structure cannot tell where it continues: on the coarsest level
96
240
  // with a hole, holes wider than the patches were filled from several shifted copies.
97
241
  constexpr int kCoarsePatchSize = 5;
98
242
 
243
+ // A patch wider than a sixth of a level found no sources there inside the image and outside
244
+ // the holes, and the fill stayed flat. Patches up to 7 keep their size: smaller ones lost the joints of bricks.
245
+ constexpr int kLevelSidesPerPatch = 6;
246
+ constexpr int kMinFittingPatchSize = 7;
247
+
99
248
  int nnf_iterations(int level)
100
249
  {
101
250
  return std::min(7, 1 + level);
@@ -191,6 +340,33 @@ namespace
191
340
  target_ptr[3] += weight;
192
341
  }
193
342
 
343
+ // A split vote: the weighted low frequencies and their weight, the detail of the best vote and its weight,
344
+ // and the weighted texture of the source pixels.
345
+ constexpr int kSplitChannels = 9;
346
+
347
+ // The mean color of the known pixels around each pixel, the part of the split votes that is averaged.
348
+ cv::Mat low_pass(const MaskedImage &image)
349
+ {
350
+ const auto size = image.size();
351
+ const auto known = [&image](int y, int x) { return is_known(image, y, x); };
352
+ const auto color = [&image](int y, int x) {
353
+ const auto *pixel = image.get_image(y, x);
354
+ return cv::Vec3d(pixel[0], pixel[1], pixel[2]);
355
+ };
356
+ cv::Mat low(size, CV_8UC3);
357
+ for (int y = 0; y < size.height; ++y)
358
+ {
359
+ for (int x = 0; x < size.width; ++x)
360
+ {
361
+ const cv::Vec3d mean = window_mean(size, y, x, 1, known, color);
362
+ auto *pixel = low.ptr<unsigned char>(y, x);
363
+ for (int c = 0; c < 3; ++c)
364
+ pixel[c] = cv::saturate_cast<unsigned char>(mean[c]);
365
+ }
366
+ }
367
+ return low;
368
+ }
369
+
194
370
  // Replaces the vote of the pixel if the new one weighs more. It is stored like the
195
371
  // sums of _weighted_copy, so the maximization step divides it by its weight.
196
372
  inline void _best_copy(const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
@@ -262,7 +438,8 @@ namespace
262
438
  bool upscaled, bool best_only, const HoleCounter *holes)
263
439
  : m_nnf(&nnf), m_vote(&vote), m_source(&source), m_holes(holes), m_source_size(nnf.source_size()),
264
440
  m_target_size(nnf.target_size()), m_source2target(source2target), m_upscaled(upscaled),
265
- m_best_only(best_only)
441
+ m_best_only(best_only), m_split(vote.channels() == kSplitChannels),
442
+ m_low(m_split ? low_pass(source) : cv::Mat())
266
443
  {
267
444
  }
268
445
 
@@ -290,7 +467,7 @@ namespace
290
467
  continue;
291
468
  // Near ties go to the patch centered closest, not the first cast, which made blocks.
292
469
  const double offset2 = static_cast<double>(di) * di + static_cast<double>(dj) * dj;
293
- cast_pair(ys, xs, yt, xt, m_best_only ? weight * (1 - 1e-5 * offset2) : weight);
470
+ cast_pair(ys, xs, yt, xt, weight, weight * (1 - 1e-5 * offset2));
294
471
  }
295
472
  }
296
473
  }
@@ -305,7 +482,7 @@ namespace
305
482
  scale * (std::int64_t{y} + patch_size + 1) - 1, scale * (std::int64_t{x} + patch_size + 1) - 1);
306
483
  }
307
484
 
308
- void cast_pair(int ys, int xs, int yt, int xt, double weight) const
485
+ void cast_pair(int ys, int xs, int yt, int xt, double weight, double best_weight) const
309
486
  {
310
487
  if (!m_source2target)
311
488
  {
@@ -314,22 +491,45 @@ namespace
314
491
  }
315
492
  if (!m_upscaled)
316
493
  {
317
- copy(ys, xs, yt, xt, weight);
494
+ copy(ys, xs, yt, xt, weight, best_weight);
318
495
  return;
319
496
  }
320
497
  for (int uy = 0; uy < 2; ++uy)
321
498
  for (int ux = 0; ux < 2; ++ux)
322
- copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight);
499
+ copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight, best_weight);
323
500
  }
324
501
 
325
- void copy(int ys, int xs, int yt, int xt, double weight) const
502
+ void copy(int ys, int xs, int yt, int xt, double weight, double best_weight) const
326
503
  {
327
504
  if (m_best_only)
328
- _best_copy(*m_source, ys, xs, *m_vote, yt, xt, weight);
505
+ _best_copy(*m_source, ys, xs, *m_vote, yt, xt, best_weight);
506
+ else if (m_split)
507
+ split_copy(ys, xs, yt, xt, weight, best_weight);
329
508
  else
330
509
  _weighted_copy(*m_source, ys, xs, *m_vote, yt, xt, weight);
331
510
  }
332
511
 
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
515
+ {
516
+ if (!is_known(*m_source, ys, xs))
517
+ return;
518
+
519
+ const auto *color = m_source->get_image(ys, xs);
520
+ const auto *low = m_low.ptr<unsigned char>(ys, xs);
521
+ auto *vote = m_vote->ptr<double>(yt, xt);
522
+ for (int c = 0; c < 3; ++c)
523
+ vote[c] += low[c] * weight;
524
+ vote[3] += weight;
525
+ vote[8] += m_source->texture().at<unsigned char>(ys, xs) * weight;
526
+ if (best_weight <= vote[7])
527
+ return;
528
+ for (int c = 0; c < 3; ++c)
529
+ vote[4 + c] = color[c] - low[c];
530
+ vote[7] = best_weight;
531
+ }
532
+
333
533
  const NearestNeighborField *m_nnf;
334
534
  cv::Mat *m_vote;
335
535
  const MaskedImage *m_source;
@@ -339,7 +539,27 @@ namespace
339
539
  bool m_source2target;
340
540
  bool m_upscaled;
341
541
  bool m_best_only;
542
+ // cv::Mat::empty() is not inline in OpenCV 5, too slow to call for every vote
543
+ bool m_split;
544
+ cv::Mat m_low; // split votes only
342
545
  };
546
+
547
+ // The known texture, and in the holes twice the mean texture of the pixels that the split votes came from:
548
+ // the distances charge a deficit below half of it, so a fill expects the full texture of its sources.
549
+ cv::Mat voted_texture(const MaskedImage &source, const cv::Mat &vote)
550
+ {
551
+ cv::Mat texture = source.texture().clone();
552
+ for (int y = 0; y < texture.rows; ++y)
553
+ {
554
+ for (int x = 0; x < texture.cols; ++x)
555
+ {
556
+ const auto *sums = vote.ptr<double>(y, x);
557
+ if (is_hole(source, y, x) && sums[3] > 0)
558
+ texture.at<unsigned char>(y, x) = cv::saturate_cast<unsigned char>(2 * sums[8] / sums[3]);
559
+ }
560
+ }
561
+ return texture;
562
+ }
343
563
  } // namespace
344
564
 
345
565
  /**
@@ -383,10 +603,25 @@ void Inpainting::_initialize_pyramid()
383
603
  m_pyramid.push_back(source);
384
604
  }
385
605
 
606
+ // The coarser levels cannot tell blurred from sharp sources by their colors, so every level
607
+ // compares the texture of the finest one, carried into the holes from their surroundings.
608
+ cv::Mat texture = texture_energy(m_pyramid[0]);
609
+ pull_push(texture);
610
+ for (std::size_t level = 0; level < m_pyramid.size(); ++level)
611
+ {
612
+ if (level > 0)
613
+ texture = halve(texture, m_pyramid[level].size());
614
+ m_pyramid[level].set_texture(to_bytes(texture));
615
+ }
616
+
386
617
  for (int level = 0; level < static_cast<int>(m_pyramid.size()); ++level)
387
618
  {
388
619
  if (has_hole(m_pyramid[level]))
389
620
  m_coarsest_hole_level = level;
621
+ const int side = std::min(m_pyramid[level].size().height, m_pyramid[level].size().width);
622
+ const int fitting = std::max(kMinFittingPatchSize, (side / kLevelSidesPerPatch - 1) / 2);
623
+ m_fitting_metrics.push_back(
624
+ fitting < m_distance_metric->patch_size() ? m_distance_metric->with_patch_size(fitting) : nullptr);
390
625
  }
391
626
  if (m_distance_metric->patch_size() < kCoarsePatchSize)
392
627
  m_coarse_metric = m_distance_metric->with_patch_size(kCoarsePatchSize);
@@ -431,12 +666,12 @@ cv::Mat Inpainting::run(bool verbose, unsigned int random_seed)
431
666
  MaskedImage Inpainting::_run_levels(
432
667
  int from, int to, MaskedImage target, const PatchDistanceMetric *metric, bool verbose)
433
668
  {
434
- m_level_metric = metric;
435
669
  for (int level = from; level >= to; --level)
436
670
  {
437
671
  trace(verbose, "Inpainting level: ", level);
438
672
 
439
673
  const MaskedImage &source = m_pyramid[level];
674
+ m_level_metric = m_fitting_metrics[level] ? m_fitting_metrics[level].get() : metric;
440
675
  const bool coarsest = level == static_cast<int>(m_pyramid.size()) - 1;
441
676
  if (coarsest)
442
677
  {
@@ -448,10 +683,10 @@ MaskedImage Inpainting::_run_levels(
448
683
  if (level < 2)
449
684
  m_source2target.reset();
450
685
  else
451
- m_source2target = coarsest ? NearestNeighborField(source, target, metric)
452
- : NearestNeighborField(source, target, metric, *m_source2target);
453
- m_target2source = coarsest ? NearestNeighborField(target, source, metric)
454
- : NearestNeighborField(target, source, metric, m_target2source);
686
+ m_source2target = coarsest ? NearestNeighborField(source, target, m_level_metric)
687
+ : NearestNeighborField(source, target, m_level_metric, *m_source2target);
688
+ m_target2source = coarsest ? NearestNeighborField(target, source, m_level_metric)
689
+ : NearestNeighborField(target, source, m_level_metric, m_target2source);
455
690
 
456
691
  trace(verbose, "Initialization done.");
457
692
 
@@ -559,14 +794,10 @@ MaskedImage Inpainting::_expectation_maximization(
559
794
  new_target = target.clone();
560
795
  }
561
796
 
562
- auto vote = cv::Mat(new_target.size(), CV_64FC4);
563
- vote.setTo(cv::Scalar::all(0));
564
-
565
797
  // The weighted mean of all overlapping patches blurs the fill, so in the last
566
798
  // iteration of the finest level each pixel takes the value of its most similar
567
799
  // patch instead (similar to "Space-Time Completion of Video", which takes the
568
- // mode of the votes). On coarser levels the mean is kept: it lays out the smooth
569
- // structure that the finer levels refine, the best vote there makes seams.
800
+ // mode of the votes). On coarser levels the best vote makes seams.
570
801
  const bool best_only = level == 0 && iter_em == nr_iters_em - 1;
571
802
 
572
803
  // Known pixels are kept on the two finest levels only. On coarser levels those
@@ -576,6 +807,12 @@ MaskedImage Inpainting::_expectation_maximization(
576
807
  const int new_level = upscaled ? level - 1 : level;
577
808
  const bool keep_known = new_level <= 1;
578
809
 
810
+ // The other votes for these levels average only their low frequencies and add the
811
+ // detail of the best vote: with the plain mean the fill stayed smooth, and smooth
812
+ // fills match smooth sources best. On coarser levels the mean lays out the structure.
813
+ const bool split = keep_known && !best_only;
814
+ auto vote = cv::Mat(new_target.size(), CV_64FC(split ? kSplitChannels : 4), cv::Scalar::all(0));
815
+
579
816
  // Votes for best patch from NNF Source->Target (completeness) and Target->Source (coherence).
580
817
  if (m_source2target)
581
818
  {
@@ -588,6 +825,11 @@ MaskedImage Inpainting::_expectation_maximization(
588
825
  // Compile votes and update pixel values.
589
826
  _maximization_step(new_target, vote, new_source, keep_known);
590
827
  trace(verbose, " Minimization step finished.");
828
+
829
+ // The fill expects the texture of its level: the known one, or that around the holes. On level 0
830
+ // that of the sources chosen on level 1: with the texture around the holes, the search there took
831
+ // blurred sources beside detailed surroundings, and parts of the fill stayed flat.
832
+ new_target.set_texture(upscaled && level == 1 ? voted_texture(new_source, vote) : new_source.texture());
591
833
  }
592
834
 
593
835
  return new_target;
@@ -628,7 +870,7 @@ void Inpainting::_expectation_step(
628
870
  const VoteCaster caster(
629
871
  nnf, source2target, vote, source, upscaled, best_only, holes.has_value() ? &holes.value() : nullptr);
630
872
  // The larger patches of m_coarse_metric would average more votes, which blurred the fills.
631
- const int patch_size = m_distance_metric->patch_size();
873
+ const int patch_size = std::min(m_distance_metric->patch_size(), m_level_metric->patch_size());
632
874
  const auto &kDistance2Similarity = distance2similarity();
633
875
  const auto source_size = nnf.source_size();
634
876
  for (int i = 0; i < source_size.height; ++i)
@@ -645,12 +887,15 @@ void Inpainting::_expectation_step(
645
887
  void Inpainting::_maximization_step(
646
888
  MaskedImage &target, const cv::Mat &vote, const MaskedImage &source, bool keep_known) const
647
889
  {
890
+ constexpr std::array<double, 3> kNoDetail = {0, 0, 0};
891
+ const bool split = vote.channels() == kSplitChannels;
648
892
  auto target_size = target.size();
649
893
  for (int i = 0; i < target_size.height; ++i)
650
894
  {
651
895
  for (int j = 0; j < target_size.width; ++j)
652
896
  {
653
897
  const double *source_ptr = vote.ptr<double>(i, j);
898
+ const double *detail = split ? source_ptr + 4 : kNoDetail.data();
654
899
  unsigned char *target_ptr = target.get_mutable_image(i, j);
655
900
 
656
901
  if (target.is_globally_masked(i, j))
@@ -671,9 +916,9 @@ void Inpainting::_maximization_step(
671
916
  if (source_ptr[3] > 0)
672
917
  {
673
918
  // All three before the stores: they may alias the votes, which changes the fast-math divisions.
674
- const auto r = cv::saturate_cast<unsigned char>(source_ptr[0] / source_ptr[3]);
675
- const auto g = cv::saturate_cast<unsigned char>(source_ptr[1] / source_ptr[3]);
676
- const auto b = cv::saturate_cast<unsigned char>(source_ptr[2] / source_ptr[3]);
919
+ const auto r = cv::saturate_cast<unsigned char>(source_ptr[0] / source_ptr[3] + detail[0]);
920
+ const auto g = cv::saturate_cast<unsigned char>(source_ptr[1] / source_ptr[3] + detail[1]);
921
+ const auto b = cv::saturate_cast<unsigned char>(source_ptr[2] / source_ptr[3] + detail[2]);
677
922
  target_ptr[0] = r;
678
923
  target_ptr[1] = g;
679
924
  target_ptr[2] = b;
@@ -35,7 +35,9 @@ private:
35
35
  const PatchDistanceMetric *m_distance_metric;
36
36
  // Searches on the levels that lay out the fill, if patch_size is smaller than kCoarsePatchSize.
37
37
  std::unique_ptr<PatchDistanceMetric> m_coarse_metric;
38
+ // Per level: smaller patches where patch_size does not fit, otherwise nullptr.
39
+ std::vector<std::unique_ptr<PatchDistanceMetric>> m_fitting_metrics;
38
40
  int m_coarsest_hole_level = -1;
39
- // Searches on the current level; the votes use patch_size.
41
+ // Searches on the current level; the votes use the smaller of its patch size and patch_size.
40
42
  const PatchDistanceMetric *m_level_metric = nullptr;
41
43
  };
@@ -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
  }
@@ -24,10 +24,12 @@ public:
24
24
  MaskedImage &operator=(MaskedImage &&) noexcept = default;
25
25
  ~MaskedImage() = default;
26
26
 
27
- // Without the gradients, which would be outdated once the clone is changed.
27
+ // Without the gradients, which would be outdated once the clone is changed. It shares the texture.
28
28
  MaskedImage clone() const
29
29
  {
30
- return MaskedImage(m_image.clone(), m_mask.clone(), m_global_mask.clone());
30
+ MaskedImage copy(m_image.clone(), m_mask.clone(), m_global_mask.clone());
31
+ copy.set_texture(m_texture);
32
+ return copy;
31
33
  }
32
34
 
33
35
  cv::Size size() const
@@ -50,15 +52,33 @@ public:
50
52
  {
51
53
  return m_has_global_mask;
52
54
  }
53
- 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
54
62
  {
55
- assert(m_image_grad_computed);
56
- return m_image_grady;
63
+ assert(m_features_computed);
64
+ return m_features;
57
65
  }
58
- const cv::Mat &gradx() const
66
+
67
+ // The texture energy that the distances compare (CV_8UC1, see Inpainting), or empty.
68
+ // It is never changed, and the resampling does not carry it over.
69
+ const cv::Mat &texture() const
70
+ {
71
+ return m_texture;
72
+ }
73
+ bool has_texture() const
74
+ {
75
+ return m_has_texture;
76
+ }
77
+ void set_texture(const cv::Mat &texture)
59
78
  {
60
- assert(m_image_grad_computed);
61
- return m_image_gradx;
79
+ m_texture = texture;
80
+ m_has_texture = !texture.empty();
81
+ m_features_computed = false;
62
82
  }
63
83
 
64
84
  void init_global_mask_mat()
@@ -67,12 +87,14 @@ public:
67
87
  m_global_mask.setTo(cv::Scalar(0));
68
88
  m_has_global_mask = !m_global_mask.empty();
69
89
  m_hole_sums_computed = false;
90
+ m_features_computed = false;
70
91
  }
71
92
  void set_global_mask_mat(const cv::Mat &other)
72
93
  {
73
94
  m_global_mask = other;
74
95
  m_has_global_mask = !other.empty();
75
96
  m_hole_sums_computed = false;
97
+ m_features_computed = false;
76
98
  }
77
99
 
78
100
  bool is_masked(int y, int x) const
@@ -87,16 +109,19 @@ public:
87
109
  {
88
110
  m_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
89
111
  m_hole_sums_computed = false;
112
+ m_features_computed = false;
90
113
  }
91
114
  void set_global_mask(int y, int x, bool value)
92
115
  {
93
116
  m_global_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
94
117
  m_hole_sums_computed = false;
118
+ m_features_computed = false;
95
119
  }
96
120
  void clear_mask()
97
121
  {
98
122
  m_mask.setTo(cv::Scalar(0));
99
123
  m_hole_sums_computed = false;
124
+ m_features_computed = false;
100
125
  }
101
126
 
102
127
  const unsigned char *get_image(int y, int x) const
@@ -112,18 +137,19 @@ public:
112
137
  MaskedImage downsample() const;
113
138
  MaskedImage upsample(int new_w, int new_h) const;
114
139
  MaskedImage upsample(int new_w, int new_h, const cv::Mat &new_global_mask) const;
115
- void compute_image_gradients() const;
140
+ void compute_features() const;
116
141
 
117
142
  private:
118
143
  cv::Mat m_image;
119
144
  cv::Mat m_mask;
120
145
  cv::Mat m_global_mask;
146
+ cv::Mat m_texture;
121
147
  // cv::Mat::empty() is not inline in OpenCV 5, too slow to call for every pixel
122
148
  bool m_has_global_mask = false;
123
- // computed on first use by compute_image_gradients
124
- mutable cv::Mat m_image_grady;
125
- mutable cv::Mat m_image_gradx;
126
- mutable bool m_image_grad_computed = false;
149
+ bool m_has_texture = false;
150
+ // computed on first use by compute_features
151
+ mutable cv::Mat m_features;
152
+ mutable bool m_features_computed = false;
127
153
  // computed on first use by contains_mask: the masked pixels outside the global mask
128
154
  // above and left of each pixel. Copies share them with the masks, so change a mask only
129
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>
@@ -52,7 +53,7 @@ namespace
52
53
  {
53
54
  ImagePair(const MaskedImage &source_image, const MaskedImage &target_image)
54
55
  : source(&source_image), target(&target_image), source_size(source_image.size()),
55
- target_size(target_image.size())
56
+ target_size(target_image.size()), textured(source_image.has_texture() && target_image.has_texture())
56
57
  {
57
58
  }
58
59
 
@@ -65,60 +66,47 @@ namespace
65
66
  const MaskedImage *target;
66
67
  cv::Size source_size;
67
68
  cv::Size target_size;
69
+ bool textured;
68
70
  };
69
71
 
70
- // 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.
71
73
  class RowPair
72
74
  {
73
75
  public:
74
76
  RowPair(const ImagePair &images, int ys, int yt)
75
77
  : m_source_width(images.source_size.width), m_target_width(images.target_size.width),
76
- m_si(images.source->image().ptr<unsigned char>(ys, 0)),
77
- m_ti(images.target->image().ptr<unsigned char>(yt, 0)),
78
- m_sm(images.source->mask().ptr<unsigned char>(ys, 0)),
79
- m_tm(images.target->mask().ptr<unsigned char>(yt, 0)),
80
- m_sgm(
81
- images.source->has_global_mask() ? images.source->global_mask().ptr<unsigned char>(ys, 0) : nullptr),
82
- m_tgm(
83
- images.target->has_global_mask() ? images.target->global_mask().ptr<unsigned char>(yt, 0) : nullptr),
84
- m_sgy(images.source->grady().ptr<unsigned char>(ys, 0)),
85
- m_tgy(images.target->grady().ptr<unsigned char>(yt, 0)),
86
- m_sgx(images.source->gradx().ptr<unsigned char>(ys, 0)),
87
- m_tgx(images.target->gradx().ptr<unsigned char>(yt, 0))
78
+ m_source(images.source->features().ptr<unsigned char>(ys)),
79
+ m_target(images.target->features().ptr<unsigned char>(yt)), m_textured(images.textured)
88
80
  {
89
81
  }
90
82
 
91
83
  // The SSD over the colors and gradients, kSSDScale where a pixel is masked or outside its image.
92
84
  int cost(int xs, int xt) const
93
85
  {
94
- // The bounds first: the masks are read only inside the image.
95
- if (xs < 0 || xs >= m_source_width || xt < 0 || xt >= m_target_width || m_sm[xs] || m_tm[xt] ||
96
- (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])
97
92
  return PatchSSDDistanceMetric::kSSDScale;
98
93
 
99
94
  int ssd = 0;
100
- for (int c = 0; c < 3; ++c)
101
- {
102
- ssd += pow2(m_si[xs * 3 + c] - m_ti[xt * 3 + c]);
103
- ssd += pow2(m_sgx[xs * 3 + c] - m_tgx[xt * 3 + c]);
104
- ssd += pow2(m_sgy[xs * 3 + c] - m_tgy[xt * 3 + c]);
105
- }
95
+ for (int k = 0; k < MaskedImage::kColorAndGradientFeatures; ++k)
96
+ ssd += pow2(s[k] - t[k]);
97
+ // Smooth fills matched smooth sources and stayed flat, so a target pixel with less than half the
98
+ // texture of its source pixel costs extra. Not a two-sided difference: it removed mortar joints.
99
+ if (m_textured)
100
+ ssd += 2 * pow2(std::max(s[MaskedImage::kTextureFeature] / 2 - t[MaskedImage::kTextureFeature], 0));
106
101
  return ssd;
107
102
  }
108
103
 
109
104
  private:
110
105
  int m_source_width;
111
106
  int m_target_width;
112
- const unsigned char *m_si;
113
- const unsigned char *m_ti;
114
- const unsigned char *m_sm;
115
- const unsigned char *m_tm;
116
- const unsigned char *m_sgm;
117
- const unsigned char *m_tgm;
118
- const unsigned char *m_sgy;
119
- const unsigned char *m_tgy;
120
- const unsigned char *m_sgx;
121
- const unsigned char *m_tgx;
107
+ const unsigned char *m_source;
108
+ const unsigned char *m_target;
109
+ bool m_textured; // false unless both images have a texture
122
110
  };
123
111
 
124
112
  // The costs of the row of the patches around s and t.
@@ -159,8 +147,8 @@ namespace
159
147
  const double limit =
160
148
  (bound + 1.0) * PatchSSDDistanceMetric::kSSDScale * side * side / PatchDistanceMetric::kDistanceScale;
161
149
 
162
- source.compute_image_gradients();
163
- target.compute_image_gradients();
150
+ source.compute_features();
151
+ target.compute_features();
164
152
 
165
153
  const ImagePair images(source, target);
166
154
  for (int dy = -patch_size; dy <= patch_size; ++dy)
@@ -44,6 +44,7 @@ Issues = "https://github.com/mauwii/PyPatchMatch/issues"
44
44
  coverage = ["gcovr"]
45
45
  # numpy and pillow for mypy, which runs without the project (--only-group dev)
46
46
  dev = [{ include-group = "test" }, "mypy", "numpy", "pillow", "pre-commit", "ruff"]
47
+ gui = ["gradio"]
47
48
  test = ["hypothesis", "pytest", "pytest-cov"]
48
49
 
49
50
  [tool.scikit-build]
@@ -59,11 +60,13 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
59
60
  # The release wheels are built from the sdist, so it has to contain the tests
60
61
  # and their input images; _version.py is generated and therefore git-ignored.
61
62
  sdist.include = [
63
+ "examples/images/camera_damaged.png",
64
+ "examples/images/camera_damaged_mask.png",
62
65
  "examples/images/chelsea.png",
63
66
  "examples/images/forest_pruned.bmp",
64
67
  "patchmatch/_version.py",
65
68
  ]
66
- sdist.exclude = [".*", "examples", "sonar-project.properties", "uv.lock"]
69
+ sdist.exclude = [".*", "examples", "gui", "sonar-project.properties", "uv.lock"]
67
70
 
68
71
  # Without a matching wheel, pip builds the sdist, which needs a C++ compiler, CMake and
69
72
  # OpenCV. If that fails, build a wheel without the native library instead of failing
@@ -3,13 +3,16 @@
3
3
  The holes are cut into images whose content is known: forest.bmp and the images listed
4
4
  in examples/images/SOURCES.md. forest_pruned.bmp and every ``*_pruned.*`` image in the
5
5
  git-ignored examples/images/local/ are added as object removals without a ground
6
- truth; their pure white pixels are the holes. grass-edge cuts a band at the right
7
- border, as in outpainting; brick-large and grass-large cut a circle of radius 242 into
8
- the center, 70 % of the image. pattern repeats almost exactly, so its error tells a fill
9
- that continues it (2 to 6) from a broken one; pattern-wide widens the hole to a radius
10
- of 60, where patch size 3 mixed the rings before 2.2 (error 37). forest-global and
11
- brick-global add a global mask, darkened in the report: the plant of
12
- examples/py_example_global_mask.py and the left third of brick.png.
6
+ truth; their pure white pixels are the holes. camera restores the damage of
7
+ camera_damaged.png, which camera_damaged_mask.png marks, against camera_aged.png; its
8
+ error covers the larger holes and the torn corner, the scratches are narrower than the
9
+ cells it compares. grass-edge cuts a band at the right border, as in outpainting;
10
+ brick-large and grass-large cut a circle of radius 242 into the center, 70 % of the
11
+ image. pattern repeats almost exactly, so its error tells a fill that continues it (2 to
12
+ 6) from a broken one; pattern-wide widens the hole to a radius of 60, where patch size 3
13
+ mixed the rings before 2.2 (error 37). forest-global and brick-global add a global mask,
14
+ darkened in the report: the plant of examples/py_example_global_mask.py and the left
15
+ third of brick.png.
13
16
 
14
17
  Usage:
15
18
  uv run python scripts/evaluate_inpainting.py
@@ -20,8 +23,8 @@ Usage:
20
23
  workflow "Fill quality" redirects it to the job summary.
21
24
 
22
25
  Besides the table, it writes examples/images/evaluation.html (git-ignored): the holes,
23
- the fills (of --baseline too) and the originals side by side. It embeds the images of
24
- examples/images/local/ as well, so keep it out of the repository.
26
+ the fills (of --baseline too) and the originals side by side, cut to the holes. It
27
+ embeds the images of examples/images/local/ as well, so keep it out of the repository.
25
28
 
26
29
  The e2e tests use measure() to check the seam and the detail of forest_pruned.bmp.
27
30
 
@@ -31,7 +34,11 @@ Columns, means over the seeds:
31
34
  detail color difference of neighbors inside the holes, relative to their
32
35
  surroundings; 0.85 to 1.1 in the original images, lower is blurrier
33
36
  error mean color error against the original on images reduced to 1/8, which
34
- compares the structure of the fill rather than its pixels
37
+ compares the structure of the fill rather than its pixels; it rates a flat
38
+ fill better than a texture whose structures are shifted
39
+ edges difference of the gradient directions from the original, in histograms
40
+ weighted by magnitude per 32x32 cell of the holes; lowest for the right
41
+ texture, also when shifted, highest for a flat fill
35
42
  fills with --baseline: whether the fills of all seeds are byte-identical to the
36
43
  baseline's, as they have to be after a change that only makes it faster
37
44
  """
@@ -70,7 +77,7 @@ HOLES = {
70
77
  "chelsea": (52, 215, 24, 40),
71
78
  "pattern": (320, 320, 40, 40),
72
79
  }
73
- COLUMNS = ("seam", "detail", "error")
80
+ COLUMNS = ("seam", "detail", "error", "edges")
74
81
 
75
82
 
76
83
  class Case(NamedTuple):
@@ -116,6 +123,9 @@ def cases() -> Iterator[Case]:
116
123
  forest = load(IMAGES / "forest.bmp")
117
124
  pruned = load(IMAGES / "forest_pruned.bmp")
118
125
  yield Case("forest", pruned, white(pruned), None)
126
+ damaged = load(IMAGES / "camera_damaged.png")
127
+ damage = np.array(Image.open(IMAGES / "camera_damaged_mask.png")) > 0
128
+ yield Case("camera", damaged, damage, load(IMAGES / "camera_aged.png"))
119
129
  yield cut("trees", forest, ellipse(forest.shape, 70, 560, 45, 55))
120
130
  meadow = np.zeros(forest.shape[:2], dtype=bool)
121
131
  meadow[318:350, 380:470] = True
@@ -176,6 +186,25 @@ def coarse_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> flo
176
186
  return float(np.abs(reduce(image)[cells] - reduce(truth)[cells]).mean())
177
187
 
178
188
 
189
+ EDGE_CELL = 32
190
+ EDGE_BINS = 8
191
+
192
+
193
+ def edge_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
194
+ """Difference of the gradient directions, weighted by magnitude, per cell of holes."""
195
+ ys, xs = np.nonzero(holes)
196
+ cells = (ys // EDGE_CELL) * (holes.shape[1] // EDGE_CELL + 1) + xs // EDGE_CELL
197
+ length = (int(cells.max()) + 1) * EDGE_BINS
198
+
199
+ def histograms(array: np.ndarray) -> np.ndarray:
200
+ gy, gx = np.gradient(array.astype(float).mean(axis=2))
201
+ bins = (np.arctan2(gy, gx) % np.pi * EDGE_BINS / np.pi).astype(int) % EDGE_BINS
202
+ weights = np.hypot(gx, gy)[holes]
203
+ return np.bincount(cells * EDGE_BINS + bins[holes], weights, length)
204
+
205
+ return float(np.abs(histograms(image) - histograms(truth)).sum() / len(ys))
206
+
207
+
179
208
  def measure(
180
209
  result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
181
210
  ) -> dict[str, float]:
@@ -196,6 +225,7 @@ def measure(
196
225
  }
197
226
  if truth is not None:
198
227
  values["error"] = coarse_error(result, truth, holes)
228
+ values["edges"] = edge_error(result, truth, holes)
199
229
  return values
200
230
 
201
231
 
@@ -280,19 +310,13 @@ figure { margin: 0; cursor: zoom-in; }
280
310
  figcaption { font-size: 13px; color: var(--muted); margin-top: 4px;
281
311
  font-variant-numeric: tabular-nums; }
282
312
  figcaption b { color: var(--fg); font-weight: 600; margin-right: 6px; }
283
- .frame { position: relative; overflow: hidden; width: 100%;
284
- aspect-ratio: var(--crop); background: var(--line); }
285
- .frame img { position: absolute; display: block; max-width: none; height: auto;
286
- left: var(--left); top: var(--top); width: var(--width); }
287
- body.whole .frame { aspect-ratio: var(--full); }
288
- body.whole .frame img { left: 0; top: 0; width: 100%; }
313
+ figure img { display: block; width: 100%; height: auto; }
289
314
  body.pixels img { image-rendering: pixelated; }
290
315
  #viewer { position: fixed; inset: 0; z-index: 10; display: flex;
291
316
  flex-direction: column; align-items: center; justify-content: center; gap: 10px;
292
317
  background: rgb(0 0 0 / 0.9); cursor: zoom-out; }
293
318
  #viewer[hidden] { display: none; }
294
- #viewer .frame { width: min(94vw, calc(86vh * var(--crop))); cursor: pointer; }
295
- body.whole #viewer .frame { width: min(94vw, calc(86vh * var(--full))); }
319
+ #viewer img { width: 94vw; height: 86vh; object-fit: contain; cursor: pointer; }
296
320
  #viewer p { color: #ddd; font-size: 14px; margin: 0; }
297
321
  """
298
322
 
@@ -304,7 +328,7 @@ function show(i) {
304
328
  index = (i + figures.length) % figures.length;
305
329
  const figure = figures[index];
306
330
  viewer.querySelector(".slot").replaceChildren(
307
- figure.querySelector(".frame").cloneNode(true));
331
+ figure.querySelector("img").cloneNode());
308
332
  viewer.querySelector("p").textContent = figure.dataset.title
309
333
  + " (" + (index + 1) + "/" + figures.length + ")";
310
334
  }
@@ -316,7 +340,7 @@ for (const figure of document.querySelectorAll("figure")) {
316
340
  });
317
341
  }
318
342
  viewer.addEventListener("click", (event) => {
319
- if (event.target.closest(".frame")) show(index + 1);
343
+ if (event.target.closest("img")) show(index + 1);
320
344
  else viewer.hidden = true;
321
345
  });
322
346
  document.addEventListener("keydown", (event) => {
@@ -327,12 +351,10 @@ document.addEventListener("keydown", (event) => {
327
351
  else return;
328
352
  event.preventDefault();
329
353
  });
330
- for (const name of ["whole", "pixels"]) {
331
- const box = document.getElementById(name);
332
- const apply = () => document.body.classList.toggle(name, box.checked);
333
- box.addEventListener("change", apply);
334
- apply(); // browsers restore the checkboxes on reload
335
- }
354
+ const pixels = document.getElementById("pixels");
355
+ const apply = () => document.body.classList.toggle("pixels", pixels.checked);
356
+ pixels.addEventListener("change", apply);
357
+ apply(); // browsers restore the checkbox on reload
336
358
  """
337
359
 
338
360
 
@@ -356,18 +378,6 @@ def crop_box(holes: np.ndarray) -> tuple[int, int, int, int]:
356
378
  return int(x0), int(y0), int(x1 - x0), int(y1 - y0)
357
379
 
358
380
 
359
- def frame(image: np.ndarray, box: tuple[int, int, int, int]) -> str:
360
- """The image, cut to ``box`` by CSS unless the whole images are shown."""
361
- height, width = image.shape[:2]
362
- x, y, w, h = box
363
- style = (
364
- f"--crop:{w / h:.5f};--full:{width / height:.5f};--width:{100 * width / w:.4f}%;"
365
- f"--left:{-100 * x / w:.4f}%;--top:{-100 * y / h:.4f}%"
366
- )
367
- source = f"data:image/png;base64,{png(image)}"
368
- return f'<div class="frame" style="{style}"><img alt="" src="{source}"></div>'
369
-
370
-
371
381
  def verdict(key: str, old: float, new: float) -> str:
372
382
  """CSS class of ``new`` compared with ``old``: better, worse or none."""
373
383
  if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
@@ -406,7 +416,7 @@ def caption(label: str, result: Result | None) -> str:
406
416
 
407
417
 
408
418
  def section(case: Case, results: list[Result], labels: list[str]) -> str:
409
- box = crop_box(case.holes)
419
+ x, y, w, h = crop_box(case.holes)
410
420
  shown = case.image.copy()
411
421
  if case.global_mask is not None:
412
422
  shown[case.global_mask] //= 2
@@ -416,7 +426,8 @@ def section(case: Case, results: list[Result], labels: list[str]) -> str:
416
426
  versions.append(("original", case.truth, None))
417
427
  figures = "".join(
418
428
  f'<figure data-title="{html.escape(case.name)}: {label}">'
419
- f"{frame(image, box)}<figcaption>{caption(label, result)}</figcaption></figure>"
429
+ f'<img alt="" src="data:image/png;base64,{png(image[y : y + h, x : x + w])}">'
430
+ f"<figcaption>{caption(label, result)}</figcaption></figure>"
420
431
  for label, image, result in versions
421
432
  )
422
433
  height, width = case.holes.shape
@@ -450,7 +461,6 @@ def write_report(
450
461
  f"<title>patchmatch evaluation</title><style>{STYLE}</style></head><body>"
451
462
  f"<main><header><h1>patchmatch evaluation</h1>{meta}</header>"
452
463
  '<div class="controls">'
453
- '<label><input type="checkbox" id="whole"> whole images</label>'
454
464
  '<label><input type="checkbox" id="pixels"> show pixels</label>'
455
465
  '<p class="hint">Click an image to enlarge it; click it, &larr; or &rarr; '
456
466
  "switches between the versions, Esc closes.</p></div>"
@@ -493,8 +503,8 @@ def markdown(rows: list[tuple[Case, list[Result]]], title: str) -> str:
493
503
  lines += [
494
504
  "",
495
505
  "Means over the seeds. seam and detail are best at 1.0, as in the original "
496
- "images, error at 0. With a baseline: baseline → current, 🟢 better and 🔴 worse "
497
- "by more than 2 % (time: 10 %).",
506
+ "images, error and edges at 0. With a baseline: baseline → current, 🟢 better "
507
+ "and 🔴 worse by more than 2 % (time: 10 %).",
498
508
  ]
499
509
  return "\n".join(lines)
500
510
 
@@ -536,7 +546,7 @@ def main() -> None:
536
546
  out = sys.stderr if args.markdown else sys.stdout
537
547
  print(f"patch_size={args.patch_size} seeds={args.seeds}", file=out)
538
548
  fills = "fills" if args.baseline else ""
539
- header = f"{'case':14} {'seam':16}{'detail':16}{'error':16}{'time':16}{fills}"
549
+ header = f"{'case':14} {''.join(f'{key:16}' for key in COLUMNS)}{'time':16}{fills}"
540
550
  print(header.rstrip(), file=out)
541
551
  rows = []
542
552
  for case in cases():
@@ -36,15 +36,21 @@ def surrounding(mask: np.ndarray, width: int = 10) -> np.ndarray:
36
36
 
37
37
 
38
38
  def assert_plausible_fill(
39
- source: np.ndarray, result: np.ndarray, excluded: np.ndarray | None = None
39
+ source: np.ndarray,
40
+ result: np.ndarray,
41
+ excluded: np.ndarray | None = None,
42
+ holes: np.ndarray | None = None,
40
43
  ) -> None:
41
44
  """Check an inpainting result of ``source`` without a ground truth image.
42
45
 
43
- ``excluded`` marks the pixels of a global mask, which are not filled.
46
+ ``excluded`` marks the pixels of a global mask, which are not filled. ``holes``
47
+ defaults to the white pixels of ``source``.
44
48
  """
45
49
  if excluded is None:
46
50
  excluded = np.zeros(source.shape[:2], dtype=bool)
47
- holes = evaluation.white(source) & ~excluded
51
+ if holes is None:
52
+ holes = evaluation.white(source)
53
+ holes = holes & ~excluded
48
54
  assert holes.any()
49
55
  assert result.shape == source.shape
50
56
  assert result.dtype == np.uint8
@@ -130,10 +136,55 @@ def test_global_mask_is_not_used_as_source(pruned_image):
130
136
  np.testing.assert_array_equal(result[excluded], source[excluded])
131
137
 
132
138
 
133
- def test_fill_is_as_detailed_as_its_surroundings():
134
- """The weighted mean of all overlapping patches blurred the fill (detail 0.69)."""
135
- source, result = fill("forest_pruned.bmp", None, 3)
136
- assert evaluation.measure(result, evaluation.white(source))["detail"] > 0.8
139
+ def test_restore_aged_photo():
140
+ """Scratches, holes and a torn corner, light but not white, given as a mask."""
141
+ source = Image.open(IMAGES / "camera_damaged.png")
142
+ mask = Image.open(IMAGES / "camera_damaged_mask.png")
143
+
144
+ result = patchmatch.inpaint(source, mask, patch_size=3)
145
+
146
+ assert_plausible_fill(np.array(source), result, holes=np.array(mask) > 0)
147
+
148
+
149
+ @pytest.mark.parametrize(
150
+ ("image", "hole", "patch_size", "limit"),
151
+ [
152
+ ("forest_pruned.bmp", None, 3, 0.8),
153
+ ("chelsea.png", evaluation.HOLES["chelsea"], 3, 0.7),
154
+ ("chelsea.png", evaluation.HOLES["chelsea"], 15, 0.4),
155
+ ],
156
+ )
157
+ def test_fill_is_as_detailed_as_its_surroundings(image, hole, patch_size, limit):
158
+ """The weighted mean of all overlapping patches blurred the fill (detail 0.69).
159
+
160
+ On the finer levels it kept the fill of the fur in chelsea smooth, which then
161
+ matched smooth sources best (detail 0.40). With patch size 15, the coarser levels
162
+ already took smooth sources, which look like fur once downsampled (detail 0.29).
163
+ """
164
+ source, result = fill(image, hole, patch_size)
165
+ assert evaluation.measure(result, evaluation.white(source))["detail"] > limit
166
+
167
+
168
+ def test_no_part_of_the_fill_is_flat():
169
+ """The finest level linked the left of the hole in chelsea to the blurred fur at
170
+ the border of the image. That part stayed flat (detail 0.30 in the flattest 16x16
171
+ block) while the whole fill reached 0.70.
172
+ """
173
+ source, result = fill("chelsea.png", evaluation.HOLES["chelsea"], 7)
174
+ holes = evaluation.white(source)
175
+ inside = holes & ~evaluation.grow(~holes, 2)
176
+ around = evaluation.grow(holes, 24) & ~evaluation.grow(holes, 2)
177
+ ys, xs = np.nonzero(inside)
178
+ details = []
179
+ for y in range(ys.min(), ys.max() + 1, 16):
180
+ for x in range(xs.min(), xs.max() + 1, 16):
181
+ block = np.zeros_like(inside)
182
+ block[y : y + 16, x : x + 16] = True
183
+ block &= inside
184
+ if block.sum() > 128:
185
+ details.append(evaluation.neighbor_difference(result, block, block))
186
+ reference = evaluation.neighbor_difference(result, around, around)
187
+ assert min(details) / reference > 0.5
137
188
 
138
189
 
139
190
  @pytest.mark.parametrize(
@@ -189,13 +240,22 @@ def test_explicit_mask_matches_implicit_white_mask(pruned_image):
189
240
  np.testing.assert_array_equal(explicit, fill("forest_pruned.bmp", None, 3)[1])
190
241
 
191
242
 
192
- # Regions of pure background in forest_pruned.bmp as (y, x, height, width).
193
- BACKGROUND_REGIONS = [(20, 400, 60, 80), (40, 520, 60, 60)]
243
+ # Regions of pure background in forest_pruned.bmp as (y, x, height, width), with the
244
+ # patch size to fill them.
245
+ BACKGROUND_REGIONS = [
246
+ ((20, 400, 60, 80), 3),
247
+ ((40, 520, 60, 60), 3),
248
+ ((25, 505, 90, 110), 15),
249
+ ]
194
250
 
195
251
 
196
- @pytest.mark.parametrize("region", BACKGROUND_REGIONS)
197
- def test_reconstructs_known_background(pruned_image, region):
198
- """Cut a hole into known background and compare with the original pixels."""
252
+ @pytest.mark.parametrize(("region", "patch_size"), BACKGROUND_REGIONS)
253
+ def test_reconstructs_known_background(pruned_image, region, patch_size):
254
+ """Cut a hole into known background and compare with the original pixels.
255
+
256
+ With patch size 15, the coarser levels found no sources inside the image and
257
+ outside the hole, and the trees got a lighter patch (0.90 of the naive error).
258
+ """
199
259
  truth = np.array(pruned_image)
200
260
  y, x, height, width = region
201
261
  mask = np.zeros(truth.shape[:2], dtype=bool)
@@ -204,7 +264,7 @@ def test_reconstructs_known_background(pruned_image, region):
204
264
 
205
265
  source = truth.copy()
206
266
  source[mask] = 255
207
- result = patchmatch.inpaint(source, mask.astype(np.uint8), patch_size=3)
267
+ result = patchmatch.inpaint(source, mask.astype(np.uint8), patch_size=patch_size)
208
268
 
209
269
  # baseline: fill the hole with the mean color of its surroundings
210
270
  naive = source.copy()
File without changes