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