PyPatchMatch 2.2.0b1__tar.gz → 2.2.0b2__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.0b2}/CLAUDE.md +3 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/PKG-INFO +15 -4
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/README.md +13 -2
- pypatchmatch-2.2.0b2/examples/images/camera_damaged.png +0 -0
- pypatchmatch-2.2.0b2/examples/images/camera_damaged_mask.png +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/_version.py +3 -3
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/inpaint.cpp +266 -21
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/inpaint.h +3 -1
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/masked_image.h +22 -2
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/nnf.cpp +11 -2
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/pyproject.toml +4 -1
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/scripts/evaluate_inpainting.py +13 -7
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/tests/test_e2e.py +73 -13
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/CMakeLists.txt +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/LICENSE +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/SECURITY.md +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/examples/images/chelsea.png +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/examples/images/forest_pruned.bmp +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/__init__.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/_lib.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/masked_image.cpp +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/nnf.h +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/pyinterface.cpp +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/csrc/pyinterface.h +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/patch_match.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/patchmatch/py.typed +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/scripts/build_opencv.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/scripts/render_readme_example.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/tests/conftest.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/tests/test_patch_match.py +0 -0
- {pypatchmatch-2.2.0b1 → pypatchmatch-2.2.0b2}/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.0b2
|
|
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.0b2'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0, 'b2')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ge960c214c'
|
|
@@ -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
|
};
|
|
@@ -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
|
|
@@ -61,6 +63,22 @@ public:
|
|
|
61
63
|
return m_image_gradx;
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
// The texture energy that the distances compare (CV_8UC1, see Inpainting), or empty.
|
|
67
|
+
// It is never changed, and the resampling does not carry it over.
|
|
68
|
+
const cv::Mat &texture() const
|
|
69
|
+
{
|
|
70
|
+
return m_texture;
|
|
71
|
+
}
|
|
72
|
+
bool has_texture() const
|
|
73
|
+
{
|
|
74
|
+
return m_has_texture;
|
|
75
|
+
}
|
|
76
|
+
void set_texture(const cv::Mat &texture)
|
|
77
|
+
{
|
|
78
|
+
m_texture = texture;
|
|
79
|
+
m_has_texture = !texture.empty();
|
|
80
|
+
}
|
|
81
|
+
|
|
64
82
|
void init_global_mask_mat()
|
|
65
83
|
{
|
|
66
84
|
m_global_mask = cv::Mat(m_mask.size(), CV_8U);
|
|
@@ -118,8 +136,10 @@ private:
|
|
|
118
136
|
cv::Mat m_image;
|
|
119
137
|
cv::Mat m_mask;
|
|
120
138
|
cv::Mat m_global_mask;
|
|
139
|
+
cv::Mat m_texture;
|
|
121
140
|
// cv::Mat::empty() is not inline in OpenCV 5, too slow to call for every pixel
|
|
122
141
|
bool m_has_global_mask = false;
|
|
142
|
+
bool m_has_texture = false;
|
|
123
143
|
// computed on first use by compute_image_gradients
|
|
124
144
|
mutable cv::Mat m_image_grady;
|
|
125
145
|
mutable cv::Mat m_image_gradx;
|
|
@@ -52,7 +52,7 @@ namespace
|
|
|
52
52
|
{
|
|
53
53
|
ImagePair(const MaskedImage &source_image, const MaskedImage &target_image)
|
|
54
54
|
: source(&source_image), target(&target_image), source_size(source_image.size()),
|
|
55
|
-
target_size(target_image.size())
|
|
55
|
+
target_size(target_image.size()), textured(source_image.has_texture() && target_image.has_texture())
|
|
56
56
|
{
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -65,6 +65,7 @@ namespace
|
|
|
65
65
|
const MaskedImage *target;
|
|
66
66
|
cv::Size source_size;
|
|
67
67
|
cv::Size target_size;
|
|
68
|
+
bool textured;
|
|
68
69
|
};
|
|
69
70
|
|
|
70
71
|
// One row of a source and a target image, both inside their images. The gradients must be computed.
|
|
@@ -84,7 +85,9 @@ namespace
|
|
|
84
85
|
m_sgy(images.source->grady().ptr<unsigned char>(ys, 0)),
|
|
85
86
|
m_tgy(images.target->grady().ptr<unsigned char>(yt, 0)),
|
|
86
87
|
m_sgx(images.source->gradx().ptr<unsigned char>(ys, 0)),
|
|
87
|
-
m_tgx(images.target->gradx().ptr<unsigned char>(yt, 0))
|
|
88
|
+
m_tgx(images.target->gradx().ptr<unsigned char>(yt, 0)),
|
|
89
|
+
m_st(images.textured ? images.source->texture().ptr<unsigned char>(ys) : nullptr),
|
|
90
|
+
m_tt(images.textured ? images.target->texture().ptr<unsigned char>(yt) : nullptr)
|
|
88
91
|
{
|
|
89
92
|
}
|
|
90
93
|
|
|
@@ -103,6 +106,10 @@ namespace
|
|
|
103
106
|
ssd += pow2(m_sgx[xs * 3 + c] - m_tgx[xt * 3 + c]);
|
|
104
107
|
ssd += pow2(m_sgy[xs * 3 + c] - m_tgy[xt * 3 + c]);
|
|
105
108
|
}
|
|
109
|
+
// Smooth fills matched smooth sources and stayed flat, so a target pixel with less than half the
|
|
110
|
+
// texture of its source pixel costs extra. Not a two-sided difference: it removed mortar joints.
|
|
111
|
+
if (m_st)
|
|
112
|
+
ssd += 2 * pow2(std::max(m_st[xs] / 2 - m_tt[xt], 0));
|
|
106
113
|
return ssd;
|
|
107
114
|
}
|
|
108
115
|
|
|
@@ -119,6 +126,8 @@ namespace
|
|
|
119
126
|
const unsigned char *m_tgy;
|
|
120
127
|
const unsigned char *m_sgx;
|
|
121
128
|
const unsigned char *m_tgx;
|
|
129
|
+
const unsigned char *m_st; // the textures, nullptr unless both images have one
|
|
130
|
+
const unsigned char *m_tt;
|
|
122
131
|
};
|
|
123
132
|
|
|
124
133
|
// The costs of the row of the patches around s and t.
|
|
@@ -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
|
|
@@ -116,6 +119,9 @@ def cases() -> Iterator[Case]:
|
|
|
116
119
|
forest = load(IMAGES / "forest.bmp")
|
|
117
120
|
pruned = load(IMAGES / "forest_pruned.bmp")
|
|
118
121
|
yield Case("forest", pruned, white(pruned), None)
|
|
122
|
+
damaged = load(IMAGES / "camera_damaged.png")
|
|
123
|
+
damage = np.array(Image.open(IMAGES / "camera_damaged_mask.png")) > 0
|
|
124
|
+
yield Case("camera", damaged, damage, load(IMAGES / "camera_aged.png"))
|
|
119
125
|
yield cut("trees", forest, ellipse(forest.shape, 70, 560, 45, 55))
|
|
120
126
|
meadow = np.zeros(forest.shape[:2], dtype=bool)
|
|
121
127
|
meadow[318:350, 380:470] = True
|
|
@@ -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
|
|
File without changes
|