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.
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/PKG-INFO +11 -2
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/README.md +10 -1
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/_version.py +3 -3
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/inpaint.cpp +73 -17
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/inpaint.h +2 -2
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/nnf.cpp +5 -2
- pypatchmatch-2.0.0rc3/scripts/evaluate_inpainting.py +453 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/tests/test_e2e.py +38 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/CMakeLists.txt +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/LICENSE +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/examples/images/forest_pruned.bmp +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/__init__.py +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/_lib.py +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/masked_image.cpp +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/masked_image.h +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/nnf.h +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/pyinterface.cpp +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/csrc/pyinterface.h +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/patch_match.py +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/patchmatch/py.typed +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/pyproject.toml +0 -0
- {pypatchmatch-2.0.0rc1 → pypatchmatch-2.0.0rc3}/scripts/build_opencv.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (2, 0, 0, '
|
|
21
|
+
__version__ = version = '2.0.0rc3'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 0, 0, 'rc3')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
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
|
|
129
|
-
//
|
|
130
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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]} → <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}×{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, ← or → '
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|