PyPatchMatch 2.0.0rc1__tar.gz → 2.0.0rc3__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.0rc1 → pypatchmatch-2.0.0rc3}/PKG-INFO +11 -2
  2. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/README.md +10 -1
  3. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/_version.py +3 -3
  4. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/inpaint.cpp +73 -17
  5. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/inpaint.h +2 -2
  6. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/nnf.cpp +5 -2
  7. pypatchmatch-2.0.0rc3/scripts/evaluate_inpainting.py +453 -0
  8. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/tests/test_e2e.py +38 -0
  9. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/CMakeLists.txt +0 -0
  10. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/LICENSE +0 -0
  11. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/examples/images/forest_pruned.bmp +0 -0
  12. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/__init__.py +0 -0
  13. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/_lib.py +0 -0
  14. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/masked_image.cpp +0 -0
  15. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/masked_image.h +0 -0
  16. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/nnf.h +0 -0
  17. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/pyinterface.cpp +0 -0
  18. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/pyinterface.h +0 -0
  19. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/patch_match.py +0 -0
  20. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/py.typed +0 -0
  21. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/pyproject.toml +0 -0
  22. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/scripts/build_opencv.py +0 -0
  23. {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/tests/test_patch_match.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.0.0rc1
3
+ Version: 2.0.0rc3
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>
@@ -76,7 +76,10 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
76
76
 
77
77
  The mask must have the same height and width as the image. If `mask` is omitted, all
78
78
  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
79
+ their values. Holes need hard edges: an anti-aliased brush leaves light pixels along
80
+ the edge of its strokes that are not pure white, so they count as known and the fill
81
+ continues them. Paint without anti-aliasing or pass a mask that covers those pixels as
82
+ well. The optional keyword argument `global_mask`, in the same format, marks
80
83
  pixels that are neither filled nor used as a source; they keep their values as well (see
81
84
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
82
85
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
@@ -121,8 +124,14 @@ uv sync # create .venv, build the library, install dev de
121
124
  uv run pre-commit install # enable ruff and the other hooks on commit
122
125
  uv run pytest # run the test suite
123
126
  uv run pytest -m "not e2e" # only the fast unit tests
127
+ uv run python scripts/evaluate_inpainting.py # measure the fill quality
124
128
  ```
125
129
 
130
+ The evaluation cuts holes into the images of `examples/images`, compares the fills with
131
+ the original content and writes `examples/images/evaluation.html`, which shows them side
132
+ by side. `--baseline` evaluates another build of the library as well, e.g. of the main
133
+ branch; the docstring of the script explains the columns.
134
+
126
135
  Releases are published to PyPI by creating a GitHub release; the version is taken from
127
136
  its tag (e.g. `v2.0.0`).
128
137
 
@@ -46,7 +46,10 @@ result = patchmatch.inpaint(image, mask, patch_size=3)
46
46
 
47
47
  The mask must have the same height and width as the image. If `mask` is omitted, all
48
48
  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
49
+ their values. Holes need hard edges: an anti-aliased brush leaves light pixels along
50
+ the edge of its strokes that are not pure white, so they count as known and the fill
51
+ continues them. Paint without anti-aliasing or pass a mask that covers those pixels as
52
+ well. The optional keyword argument `global_mask`, in the same format, marks
50
53
  pixels that are neither filled nor used as a source; they keep their values as well (see
51
54
  [examples/py_example_global_mask.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example_global_mask.py)).
52
55
  `patchmatch.patchmatch_available` tells whether the native library could be loaded.
@@ -91,8 +94,14 @@ uv sync # create .venv, build the library, install dev de
91
94
  uv run pre-commit install # enable ruff and the other hooks on commit
92
95
  uv run pytest # run the test suite
93
96
  uv run pytest -m "not e2e" # only the fast unit tests
97
+ uv run python scripts/evaluate_inpainting.py # measure the fill quality
94
98
  ```
95
99
 
100
+ The evaluation cuts holes into the images of `examples/images`, compares the fills with
101
+ the original content and writes `examples/images/evaluation.html`, which shows them side
102
+ by side. `--baseline` evaluates another build of the library as well, e.g. of the main
103
+ branch; the docstring of the script explains the columns.
104
+
96
105
  Releases are published to PyPI by creating a GitHub release; the version is taken from
97
106
  its tag (e.g. `v2.0.0`).
98
107
 
@@ -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.0rc1'
22
- __version_tuple__ = version_tuple = (2, 0, 0, 'rc1')
21
+ __version__ = version = '2.0.0rc3'
22
+ __version_tuple__ = version_tuple = (2, 0, 0, 'rc3')
23
23
 
24
- __commit_id__ = commit_id = 'g75098309c'
24
+ __commit_id__ = commit_id = 'g70cf3a861'
@@ -48,6 +48,25 @@ namespace
48
48
  target_ptr[c] += static_cast<double>(source_ptr[c]) * weight;
49
49
  target_ptr[3] += weight;
50
50
  }
51
+
52
+ // Replaces the vote of the pixel if the new one weighs more. It is stored like the
53
+ // sums of _weighted_copy, so the maximization step divides it by its weight.
54
+ inline void _best_copy(const MaskedImage &source, int ys, int xs, cv::Mat &target, int yt, int xt, double weight)
55
+ {
56
+ if (source.is_masked(ys, xs))
57
+ return;
58
+ if (source.is_globally_masked(ys, xs))
59
+ return;
60
+
61
+ auto source_ptr = source.get_image(ys, xs);
62
+ auto target_ptr = target.ptr<double>(yt, xt);
63
+ if (weight <= target_ptr[3])
64
+ return;
65
+
66
+ for (int c = 0; c < 3; ++c)
67
+ target_ptr[c] = static_cast<double>(source_ptr[c]) * weight;
68
+ target_ptr[3] = weight;
69
+ }
51
70
  }
52
71
 
53
72
  /**
@@ -125,16 +144,22 @@ cv::Mat Inpainting::run(bool verbose, bool verbose_visualize, unsigned int rando
125
144
  target = _expectation_maximization(source, target, level, verbose);
126
145
  }
127
146
 
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.
147
+ // Only the holes are filled. The maximization step of level 0 keeps the known pixels,
148
+ // but the pyramid leaves globally masked pixels black, which are neither filled nor
149
+ // used as a source. Copy every pixel outside the holes from the input.
150
+ // A plain loop instead of OpenCV matrix expressions: those pull OpenCV's whole
151
+ // expression module into the statically linked library, 40 % more on disk.
131
152
  cv::Mat result = target.image();
132
- if (result.empty()) // OpenCV rejects empty operands in matrix expressions
133
- return result;
134
- cv::Mat keep = m_initial.mask() == 0;
135
- if (!m_initial.global_mask().empty())
136
- keep |= m_initial.global_mask() != 0;
137
- m_initial.image().copyTo(result, keep);
153
+ for (int y = 0; y < result.rows; ++y)
154
+ {
155
+ for (int x = 0; x < result.cols; ++x)
156
+ {
157
+ if (m_initial.is_masked(y, x) && !m_initial.is_globally_masked(y, x))
158
+ continue;
159
+ const unsigned char *input = m_initial.get_image(y, x);
160
+ std::copy(input, input + 3, result.ptr<unsigned char>(y, x));
161
+ }
162
+ }
138
163
  return result;
139
164
  }
140
165
 
@@ -197,16 +222,28 @@ MaskedImage Inpainting::_expectation_maximization(MaskedImage source, MaskedImag
197
222
  auto vote = cv::Mat(new_target.size(), CV_64FC4);
198
223
  vote.setTo(cv::Scalar::all(0));
199
224
 
225
+ // The weighted mean of all overlapping patches blurs the fill, so in the last
226
+ // iteration of the finest level each pixel takes the value of its most similar
227
+ // patch instead (similar to "Space-Time Completion of Video", which takes the
228
+ // mode of the votes). On coarser levels the mean is kept: it lays out the smooth
229
+ // structure that the finer levels refine, the best vote there makes seams.
230
+ const bool best_only = level == 0 && iter_em == nr_iters_em - 1;
231
+
200
232
  // Votes for best patch from NNF Source->Target (completeness) and Target->Source (coherence).
201
- _expectation_step(m_source2target, 1, vote, new_source, upscaled);
233
+ _expectation_step(m_source2target, true, vote, new_source, upscaled, best_only);
202
234
  if (verbose)
203
235
  std::cerr << " Expectation source to target finished." << std::endl;
204
- _expectation_step(m_target2source, 0, vote, new_source, upscaled);
236
+ _expectation_step(m_target2source, false, vote, new_source, upscaled, best_only);
205
237
  if (verbose)
206
238
  std::cerr << " Expectation target to source finished." << std::endl;
207
239
 
208
240
  // Compile votes and update pixel values.
209
- _maximization_step(new_target, vote);
241
+ // Known pixels are kept on the two finest levels only. On coarser levels those
242
+ // next to the holes average only the known part of their downsampling kernel,
243
+ // estimates that reach into the holes; keeping them there made the fills copy
244
+ // smooth regions, e.g. a concrete wall into the foliage above it.
245
+ const int new_level = upscaled ? level - 1 : level;
246
+ _maximization_step(new_target, vote, new_source, new_level <= 1);
210
247
  if (verbose)
211
248
  std::cerr << " Minimization step finished." << std::endl;
212
249
  }
@@ -217,13 +254,22 @@ MaskedImage Inpainting::_expectation_maximization(MaskedImage source, MaskedImag
217
254
  // Expectation step: vote for best estimations of each pixel.
218
255
  void Inpainting::_expectation_step(
219
256
  const NearestNeighborField &nnf, bool source2target,
220
- cv::Mat &vote, const MaskedImage &source, bool upscaled)
257
+ cv::Mat &vote, const MaskedImage &source, bool upscaled, bool best_only)
221
258
  {
222
259
  auto source_size = nnf.source_size();
223
260
  auto target_size = nnf.target_size();
224
261
  const int patch_size = m_distance_metric->patch_size();
225
262
  const auto &kDistance2Similarity = distance2similarity();
226
263
 
264
+ double w = 0;
265
+ auto copy = [&](int ys, int xs, int yt, int xt)
266
+ {
267
+ if (best_only)
268
+ _best_copy(source, ys, xs, vote, yt, xt, w);
269
+ else
270
+ _weighted_copy(source, ys, xs, vote, yt, xt, w);
271
+ };
272
+
227
273
  for (int i = 0; i < source_size.height; ++i)
228
274
  {
229
275
  for (int j = 0; j < source_size.width; ++j)
@@ -231,7 +277,7 @@ void Inpainting::_expectation_step(
231
277
  if (nnf.source().is_globally_masked(i, j))
232
278
  continue;
233
279
  int yp = nnf.at(i, j, 0), xp = nnf.at(i, j, 1), dp = nnf.at(i, j, 2);
234
- double w = kDistance2Similarity[dp];
280
+ w = kDistance2Similarity[dp];
235
281
 
236
282
  for (int di = -patch_size; di <= patch_size; ++di)
237
283
  {
@@ -259,13 +305,13 @@ void Inpainting::_expectation_step(
259
305
  {
260
306
  for (int ux = 0; ux < 2; ++ux)
261
307
  {
262
- _weighted_copy(source, 2 * ys + uy, 2 * xs + ux, vote, 2 * yt + uy, 2 * xt + ux, w);
308
+ copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux);
263
309
  }
264
310
  }
265
311
  }
266
312
  else
267
313
  {
268
- _weighted_copy(source, ys, xs, vote, yt, xt, w);
314
+ copy(ys, xs, yt, xt);
269
315
  }
270
316
  }
271
317
  }
@@ -274,7 +320,7 @@ void Inpainting::_expectation_step(
274
320
  }
275
321
 
276
322
  // Maximization Step: maximum likelihood of target pixel.
277
- void Inpainting::_maximization_step(MaskedImage &target, const cv::Mat &vote)
323
+ void Inpainting::_maximization_step(MaskedImage &target, const cv::Mat &vote, const MaskedImage &source, bool keep_known) const
278
324
  {
279
325
  auto target_size = target.size();
280
326
  for (int i = 0; i < target_size.height; ++i)
@@ -289,6 +335,16 @@ void Inpainting::_maximization_step(MaskedImage &target, const cv::Mat &vote)
289
335
  continue;
290
336
  }
291
337
 
338
+ // Known pixels keep their values. Otherwise the votes of patches that overlap
339
+ // a hole change them, the holes are filled to match the changed surroundings,
340
+ // and a seam appears once the known pixels are restored at the end.
341
+ if (keep_known && !source.is_masked(i, j))
342
+ {
343
+ const unsigned char *known = source.get_image(i, j);
344
+ std::copy(known, known + 3, target_ptr);
345
+ continue;
346
+ }
347
+
292
348
  if (source_ptr[3] > 0)
293
349
  {
294
350
  unsigned char r = cv::saturate_cast<unsigned char>(source_ptr[0] / source_ptr[3]);
@@ -15,8 +15,8 @@ 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(const NearestNeighborField &nnf, bool source2target, cv::Mat &vote, const MaskedImage &source, bool upscaled, bool best_only);
19
+ void _maximization_step(MaskedImage &target, const cv::Mat &vote, const MaskedImage &source, bool keep_known) const;
20
20
 
21
21
  MaskedImage m_initial;
22
22
  std::vector<MaskedImage> m_pyramid;
@@ -98,8 +98,11 @@ void NearestNeighborField::_initialize_field_from(const NearestNeighborField &ot
98
98
  auto this_value = mutable_ptr(i, j);
99
99
  auto other_value = other.ptr(ilow, jlow);
100
100
 
101
- this_value[0] = static_cast<int>(other_value[0] * fi);
102
- this_value[1] = static_cast<int>(other_value[1] * fj);
101
+ // Keep the offset within the coarse pixel, so that neighbors still point to
102
+ // neighbors. Without it, both pixels of a pair point to the same target pixel,
103
+ // which makes the upscaled fill blocky.
104
+ this_value[0] = clamp(static_cast<int>(other_value[0] * fi + (i - ilow * fi)), 0, target_size().height - 1);
105
+ this_value[1] = clamp(static_cast<int>(other_value[1] * fj + (j - jlow * fj)), 0, target_size().width - 1);
103
106
  this_value[2] = _distance(i, j, this_value[0], this_value[1]);
104
107
  }
105
108
  }
@@ -0,0 +1,453 @@
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
+ # SonarCloud's path traversal rule for command line arguments (pythonsecurity:S8707)
386
+ # flags the patch size, the seeds and the library paths in the content. The path is
387
+ # fixed, and they only appear as escaped text, so the issue is suppressed.
388
+ REPORT.write_text( # NOSONAR
389
+ "<!doctype html>\n"
390
+ '<html lang="en"><head><meta charset="utf-8">'
391
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
392
+ f"<title>patchmatch evaluation</title><style>{STYLE}</style></head><body>"
393
+ f"<main><header><h1>patchmatch evaluation</h1>{meta}</header>"
394
+ '<div class="controls">'
395
+ '<label><input type="checkbox" id="whole"> whole images</label>'
396
+ '<label><input type="checkbox" id="pixels"> show pixels</label>'
397
+ '<p class="hint">Click an image to enlarge it; click it, &larr; or &rarr; '
398
+ "switches between the versions, Esc closes.</p></div>"
399
+ f"<table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table>"
400
+ f'{sections}</main><div id="viewer" hidden><div class="slot"></div><p></p>'
401
+ f"</div><script>{SCRIPT}</script></body></html>\n",
402
+ encoding="utf-8",
403
+ )
404
+
405
+
406
+ def describe(args: argparse.Namespace, labels: list[str], paths: list[str]) -> str:
407
+ lines = [
408
+ f"{datetime.now():%Y-%m-%d %H:%M}, patch_size {args.patch_size}, "
409
+ f"{args.seeds} seed(s), fills of seed 0",
410
+ *(f"{label}: {path}" for label, path in zip(labels, paths, strict=True)),
411
+ ]
412
+ return "".join(f'<p class="meta">{html.escape(line)}</p>' for line in lines)
413
+
414
+
415
+ def main() -> None:
416
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
417
+ parser.add_argument("--library", type=Path, help="library file to load instead")
418
+ parser.add_argument("--baseline", type=Path, help="library file to compare with")
419
+ parser.add_argument("--patch-size", type=int, default=3)
420
+ parser.add_argument("--seeds", type=int, default=3)
421
+ parser.add_argument("--cases", help="comma-separated names, default: all")
422
+ args = parser.parse_args()
423
+ if args.seeds < 1:
424
+ parser.error("--seeds must be at least 1")
425
+
426
+ current = load_library(args.library.resolve()) if args.library else patch_match._lib
427
+ if current is None:
428
+ parser.error("the installed package has no native library, pass --library")
429
+ libraries, labels = [current], ["fill"]
430
+ if args.baseline:
431
+ libraries.insert(0, load_library(args.baseline.resolve()))
432
+ labels = ["baseline", "current"]
433
+ selected = set(args.cases.split(",")) if args.cases else None
434
+
435
+ print(f"patch_size={args.patch_size} seeds={args.seeds}")
436
+ print(f"{'case':10} {'seam':16}{'detail':16}{'error':16}time")
437
+ rows = []
438
+ for case in cases():
439
+ if selected and case.name not in selected:
440
+ continue
441
+ results = [
442
+ evaluate(lib, case, args.patch_size, args.seeds) for lib in libraries
443
+ ]
444
+ print_row(case.name, results)
445
+ rows.append((case, results))
446
+
447
+ meta = describe(args, labels, [lib._name for lib in libraries])
448
+ write_report(rows, labels, meta)
449
+ print(f"report: {REPORT}")
450
+
451
+
452
+ if __name__ == "__main__":
453
+ 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