PyPatchMatch 2.2.0b1__tar.gz → 2.2.0b3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/CLAUDE.md +3 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/PKG-INFO +15 -4
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/README.md +13 -2
- pypatchmatch-2.2.0b3/examples/images/camera_damaged.png +0 -0
- pypatchmatch-2.2.0b3/examples/images/camera_damaged_mask.png +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/_version.py +3 -3
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/inpaint.cpp +266 -21
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/inpaint.h +3 -1
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/masked_image.cpp +13 -9
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/masked_image.h +39 -13
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/nnf.cpp +23 -35
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/pyproject.toml +4 -1
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/evaluate_inpainting.py +55 -45
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_e2e.py +73 -13
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/CMakeLists.txt +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/LICENSE +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/SECURITY.md +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/examples/images/chelsea.png +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/examples/images/forest_pruned.bmp +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/__init__.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/_lib.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/nnf.h +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/pyinterface.cpp +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/csrc/pyinterface.h +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/patch_match.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/patchmatch/py.typed +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/build_opencv.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/scripts/render_readme_example.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/conftest.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_patch_match.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b3}/tests/test_properties.py +0 -0
|
@@ -62,6 +62,9 @@ Other entry points:
|
|
|
62
62
|
- `uv run python scripts/render_readme_example.py` runs `py_example.py` and renders the
|
|
63
63
|
picture at the top of the README, `examples/images/readme_example.jpg`; rerun it and
|
|
64
64
|
commit the picture after changes that affect the fills.
|
|
65
|
+
- `uv run --group gui gui/app.py` starts the Gradio GUI (paint holes, compare the fill).
|
|
66
|
+
No CI job installs gradio, so after API changes run its test with
|
|
67
|
+
`uv run --group gui pytest gui --no-cov` and try the GUI by hand.
|
|
65
68
|
- CMake options: the `option()` lines of `CMakeLists.txt`. `PATCHMATCH_SANITIZE` turns
|
|
66
69
|
off fast math, which lets the compiler drop checks.
|
|
67
70
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyPatchMatch
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.0b3
|
|
4
4
|
Summary: This library implements the PatchMatch based inpainting algorithm.
|
|
5
5
|
Keywords: inpainting,patchmatch,image-processing,opencv
|
|
6
6
|
Author-Email: The InvokeAI Project <lincoln.stein@gmail.com>, Jiayuan Mao <maojiayuan@gmail.com>, Kyle Schouviller <kyle0654@hotmail.com>, Matthias Wild <mauwii@outlook.de>, Younesse ANDAM <younesse.andam@gmail.com>
|
|
@@ -41,9 +41,12 @@ Description-Content-Type: text/markdown
|
|
|
41
41
|
This library implements the PatchMatch based inpainting algorithm. It provides both C++
|
|
42
42
|
and Python interfaces. This implementation is heavily based on the implementation by
|
|
43
43
|
Younesse ANDAM: [younesse-cv/PatchMatch](https://github.com/younesse-cv/PatchMatch),
|
|
44
|
-
with some bug fixes, and updates.
|
|
44
|
+
with some bug fixes, and updates. This repository continues
|
|
45
|
+
[vacancy/PyPatchMatch](https://github.com/vacancy/PyPatchMatch) by Jiayuan Mao, through
|
|
46
|
+
the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
|
|
47
|
+
[invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
|
|
45
48
|
|
|
46
|
-

|
|
47
50
|
|
|
48
51
|
From left to right: the photo, the input of
|
|
49
52
|
[examples/py_example.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example.py)
|
|
@@ -100,7 +103,9 @@ The previous import path `from patchmatch import patch_match` keeps working.
|
|
|
100
103
|
pixels in each direction. Larger patches follow larger structures but are much slower;
|
|
101
104
|
the default of 15 compares 31x31 patches, the examples use 3. Below 5, the coarse levels
|
|
102
105
|
that lay out the fill are also tried with a radius of 5, which is kept where it fits
|
|
103
|
-
the surroundings better, so that repeating patterns continue across larger holes.
|
|
106
|
+
the surroundings better, so that repeating patterns continue across larger holes. Above
|
|
107
|
+
7, coarse levels narrower than six patches use the largest radius that fits, but at
|
|
108
|
+
least 7: larger patches found no sources there and left the fill flat.
|
|
104
109
|
|
|
105
110
|
`patchmatch.set_random_seed(seed)` sets the seed of the randomized search, modulo
|
|
106
111
|
`2**32`, and `patchmatch.set_verbose(True)` prints the progress of the native code to
|
|
@@ -138,8 +143,14 @@ uv run pre-commit install # enable ruff and the other hooks on commit
|
|
|
138
143
|
uv run pytest # run the test suite
|
|
139
144
|
uv run pytest -m "not e2e" # only the fast unit tests
|
|
140
145
|
uv run python scripts/evaluate_inpainting.py # measure the fill quality
|
|
146
|
+
uv run --group gui gui/app.py # draw masks in the browser, http://127.0.0.1:7860
|
|
141
147
|
```
|
|
142
148
|
|
|
149
|
+
The GUI tries new images quickly, without writing a script for each one, e.g. to pick
|
|
150
|
+
new examples or to verify edge cases: load an image, paint the holes (every painted
|
|
151
|
+
pixel is one) and compare the original with the fill. Its test runs with
|
|
152
|
+
`uv run --group gui pytest gui --no-cov`.
|
|
153
|
+
|
|
143
154
|
The evaluation cuts holes into the images of `examples/images`, compares the fills with
|
|
144
155
|
the original content and writes `examples/images/evaluation.html`, which shows them side
|
|
145
156
|
by side. `--baseline` evaluates another build of the library as well, e.g. of the main
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
This library implements the PatchMatch based inpainting algorithm. It provides both C++
|
|
12
12
|
and Python interfaces. This implementation is heavily based on the implementation by
|
|
13
13
|
Younesse ANDAM: [younesse-cv/PatchMatch](https://github.com/younesse-cv/PatchMatch),
|
|
14
|
-
with some bug fixes, and updates.
|
|
14
|
+
with some bug fixes, and updates. This repository continues
|
|
15
|
+
[vacancy/PyPatchMatch](https://github.com/vacancy/PyPatchMatch) by Jiayuan Mao, through
|
|
16
|
+
the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
|
|
17
|
+
[invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
|
|
15
18
|
|
|
16
19
|

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