PyPatchMatch 2.2.0b2__tar.gz → 2.2.0rc1__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.0b2 → pypatchmatch-2.2.0rc1}/CLAUDE.md +3 -1
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/PKG-INFO +11 -6
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/README.md +9 -4
- pypatchmatch-2.2.0rc1/examples/images/camera_aged.png +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/forest_pruned.bmp +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/_version.py +3 -3
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/inpaint.cpp +53 -17
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/masked_image.cpp +13 -9
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/masked_image.h +19 -13
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/nnf.cpp +19 -40
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/pyproject.toml +1 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/evaluate_inpainting.py +135 -64
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_e2e.py +10 -2
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/CMakeLists.txt +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/LICENSE +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/SECURITY.md +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/camera_damaged.png +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/camera_damaged_mask.png +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/examples/images/chelsea.png +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/__init__.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/_lib.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/inpaint.h +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/nnf.h +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/pyinterface.cpp +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/csrc/pyinterface.h +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/patch_match.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/patchmatch/py.typed +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/build_opencv.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/scripts/render_readme_example.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/conftest.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_patch_match.py +0 -0
- {pypatchmatch-2.2.0b2 → pypatchmatch-2.2.0rc1}/tests/test_properties.py +0 -0
|
@@ -134,4 +134,6 @@ CMake installs the shared library into the package directory.
|
|
|
134
134
|
`invoke-ai/PyPatchMatch`. `gh` resolves the default repository to upstream, so pass
|
|
135
135
|
`-R mauwii/PyPatchMatch`.
|
|
136
136
|
- Releases: creating a GitHub release with a tag like `v2.0.0` (or `v2.0.0rc2` for
|
|
137
|
-
pre-releases) builds the wheels and publishes them to PyPI.
|
|
137
|
+
pre-releases) builds the wheels and publishes them to PyPI. The workflow `Fill
|
|
138
|
+
quality` compares the release with the last stable one; read its summary before
|
|
139
|
+
approving the job `publish`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyPatchMatch
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.0rc1
|
|
4
4
|
Summary: This library implements the PatchMatch based inpainting algorithm.
|
|
5
5
|
Keywords: inpainting,patchmatch,image-processing,opencv
|
|
6
6
|
Author-Email: The InvokeAI Project <lincoln.stein@gmail.com>, Jiayuan Mao <maojiayuan@gmail.com>, Kyle Schouviller <kyle0654@hotmail.com>, Matthias Wild <mauwii@outlook.de>, Younesse ANDAM <younesse.andam@gmail.com>
|
|
@@ -30,13 +30,18 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
# PatchMatch based Inpainting
|
|
32
32
|
|
|
33
|
-
[](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
|
|
34
|
-
[](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
|
|
35
33
|
[](https://pypi.org/project/PyPatchMatch/)
|
|
36
|
-
[](https://pypi.org/project/PyPatchMatch/)
|
|
35
|
+
[](https://pepy.tech/projects/pypatchmatch)
|
|
36
|
+
[](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
|
|
37
|
+
|
|
38
|
+
[](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml?query=branch%3Amain)
|
|
39
|
+
[](https://sonarcloud.io/component_measures?id=mauwii_PyPatchMatch&metric=coverage)
|
|
40
|
+
[](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
|
|
41
|
+
|
|
42
|
+
[](https://github.com/astral-sh/uv)
|
|
37
43
|
[](https://github.com/astral-sh/ruff)
|
|
38
44
|
[](https://github.com/DietrichGebert/ponytail)
|
|
39
|
-
[](https://github.com/JuliusBrussee/caveman)
|
|
40
45
|
|
|
41
46
|
This library implements the PatchMatch based inpainting algorithm. It provides both C++
|
|
42
47
|
and Python interfaces. This implementation is heavily based on the implementation by
|
|
@@ -46,7 +51,7 @@ with some bug fixes, and updates. This repository continues
|
|
|
46
51
|
the forks [lkwq007/PyPatchMatch](https://github.com/lkwq007/PyPatchMatch) and
|
|
47
52
|
[invoke-ai/PyPatchMatch](https://github.com/invoke-ai/PyPatchMatch).
|
|
48
53
|
|
|
49
|
-

|
|
50
55
|
|
|
51
56
|
From left to right: the photo, the input of
|
|
52
57
|
[examples/py_example.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example.py)
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
# PatchMatch based Inpainting
|
|
2
2
|
|
|
3
|
-
[](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
|
|
4
|
-
[](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
|
|
5
3
|
[](https://pypi.org/project/PyPatchMatch/)
|
|
6
|
-
[](https://pypi.org/project/PyPatchMatch/)
|
|
5
|
+
[](https://pepy.tech/projects/pypatchmatch)
|
|
6
|
+
[](https://github.com/mauwii/PyPatchMatch/blob/main/LICENSE)
|
|
7
|
+
|
|
8
|
+
[](https://github.com/mauwii/PyPatchMatch/actions/workflows/ci.yml?query=branch%3Amain)
|
|
9
|
+
[](https://sonarcloud.io/component_measures?id=mauwii_PyPatchMatch&metric=coverage)
|
|
10
|
+
[](https://sonarcloud.io/summary/new_code?id=mauwii_PyPatchMatch)
|
|
11
|
+
|
|
12
|
+
[](https://github.com/astral-sh/uv)
|
|
7
13
|
[](https://github.com/astral-sh/ruff)
|
|
8
14
|
[](https://github.com/DietrichGebert/ponytail)
|
|
9
|
-
[](https://github.com/JuliusBrussee/caveman)
|
|
10
15
|
|
|
11
16
|
This library implements the PatchMatch based inpainting algorithm. It provides both C++
|
|
12
17
|
and Python interfaces. This implementation is heavily based on the implementation by
|
|
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.0rc1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0, 'rc1')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'ge40b76e89'
|
|
@@ -369,18 +369,15 @@ namespace
|
|
|
369
369
|
|
|
370
370
|
// Replaces the vote of the pixel if the new one weighs more. It is stored like the
|
|
371
371
|
// sums of _weighted_copy, so the maximization step divides it by its weight.
|
|
372
|
-
inline void _best_copy(
|
|
372
|
+
inline void _best_copy(
|
|
373
|
+
const unsigned char *color, const cv::Vec3d &shift, cv::Mat &target, int yt, int xt, double weight)
|
|
373
374
|
{
|
|
374
|
-
if (source.is_masked(ys, xs))
|
|
375
|
-
return;
|
|
376
|
-
|
|
377
|
-
auto source_ptr = source.get_image(ys, xs);
|
|
378
375
|
auto target_ptr = target.ptr<double>(yt, xt);
|
|
379
376
|
if (weight <= target_ptr[3])
|
|
380
377
|
return;
|
|
381
378
|
|
|
382
379
|
for (int c = 0; c < 3; ++c)
|
|
383
|
-
target_ptr[c] = static_cast<double>(
|
|
380
|
+
target_ptr[c] = (static_cast<double>(color[c]) + shift[c]) * weight;
|
|
384
381
|
target_ptr[3] = weight;
|
|
385
382
|
}
|
|
386
383
|
|
|
@@ -451,6 +448,7 @@ namespace
|
|
|
451
448
|
const int xp = m_nnf->at(i, j, 1);
|
|
452
449
|
if (m_holes && !votes_for_hole(m_source2target ? yp : i, m_source2target ? xp : j, patch_size))
|
|
453
450
|
return;
|
|
451
|
+
const cv::Vec3d shift = m_holes ? color_shift(i, j, yp, xp, patch_size) : cv::Vec3d();
|
|
454
452
|
for (int di = -patch_size; di <= patch_size; ++di)
|
|
455
453
|
{
|
|
456
454
|
for (int dj = -patch_size; dj <= patch_size; ++dj)
|
|
@@ -467,12 +465,47 @@ namespace
|
|
|
467
465
|
continue;
|
|
468
466
|
// Near ties go to the patch centered closest, not the first cast, which made blocks.
|
|
469
467
|
const double offset2 = static_cast<double>(di) * di + static_cast<double>(dj) * dj;
|
|
470
|
-
cast_pair(ys, xs, yt, xt, weight, weight * (1 - 1e-5 * offset2));
|
|
468
|
+
cast_pair(ys, xs, yt, xt, weight, weight * (1 - 1e-5 * offset2), shift);
|
|
471
469
|
}
|
|
472
470
|
}
|
|
473
471
|
}
|
|
474
472
|
|
|
475
473
|
private:
|
|
474
|
+
// The mean color difference of the voted image to the voting source over the pixels known in both patches.
|
|
475
|
+
// The votes of the finest levels add it: their sources lie elsewhere in the image, and with their colors
|
|
476
|
+
// the fill of a vignetted photo came out lighter than the colors the coarser levels had laid out.
|
|
477
|
+
cv::Vec3d color_shift(int i, int j, int yp, int xp, int patch_size) const
|
|
478
|
+
{
|
|
479
|
+
const MaskedImage &source = m_nnf->source();
|
|
480
|
+
const MaskedImage &target = m_nnf->target();
|
|
481
|
+
cv::Vec3d sum;
|
|
482
|
+
int count = 0;
|
|
483
|
+
for (int di = -patch_size; di <= patch_size; ++di)
|
|
484
|
+
{
|
|
485
|
+
for (int dj = -patch_size; dj <= patch_size; ++dj)
|
|
486
|
+
{
|
|
487
|
+
const int ys = i + di;
|
|
488
|
+
const int xs = j + dj;
|
|
489
|
+
const int yt = yp + di;
|
|
490
|
+
const int xt = xp + dj;
|
|
491
|
+
if (ys < 0 || ys >= m_source_size.height || xs < 0 || xs >= m_source_size.width || yt < 0 ||
|
|
492
|
+
yt >= m_target_size.height || xt < 0 || xt >= m_target_size.width)
|
|
493
|
+
continue;
|
|
494
|
+
if (!is_known(source, ys, xs) || !is_known(target, yt, xt))
|
|
495
|
+
continue;
|
|
496
|
+
const auto *s = source.get_image(ys, xs);
|
|
497
|
+
const auto *t = target.get_image(yt, xt);
|
|
498
|
+
for (int c = 0; c < 3; ++c)
|
|
499
|
+
sum[c] += s[c] - t[c];
|
|
500
|
+
++count;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (count == 0)
|
|
504
|
+
return {};
|
|
505
|
+
// The voted image is the source of the coherence field and the target of the completeness field.
|
|
506
|
+
return sum * ((m_source2target ? -1.0 : 1.0) / count);
|
|
507
|
+
}
|
|
508
|
+
|
|
476
509
|
// Whether the patch around (y, x) of the voted image, scaled like the votes, has a hole.
|
|
477
510
|
bool votes_for_hole(int y, int x, int patch_size) const
|
|
478
511
|
{
|
|
@@ -482,7 +515,7 @@ namespace
|
|
|
482
515
|
scale * (std::int64_t{y} + patch_size + 1) - 1, scale * (std::int64_t{x} + patch_size + 1) - 1);
|
|
483
516
|
}
|
|
484
517
|
|
|
485
|
-
void cast_pair(int ys, int xs, int yt, int xt, double weight, double best_weight) const
|
|
518
|
+
void cast_pair(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
|
|
486
519
|
{
|
|
487
520
|
if (!m_source2target)
|
|
488
521
|
{
|
|
@@ -491,27 +524,30 @@ namespace
|
|
|
491
524
|
}
|
|
492
525
|
if (!m_upscaled)
|
|
493
526
|
{
|
|
494
|
-
copy(ys, xs, yt, xt, weight, best_weight);
|
|
527
|
+
copy(ys, xs, yt, xt, weight, best_weight, shift);
|
|
495
528
|
return;
|
|
496
529
|
}
|
|
497
530
|
for (int uy = 0; uy < 2; ++uy)
|
|
498
531
|
for (int ux = 0; ux < 2; ++ux)
|
|
499
|
-
copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight, best_weight);
|
|
532
|
+
copy(2 * ys + uy, 2 * xs + ux, 2 * yt + uy, 2 * xt + ux, weight, best_weight, shift);
|
|
500
533
|
}
|
|
501
534
|
|
|
502
|
-
void copy(int ys, int xs, int yt, int xt, double weight, double best_weight) const
|
|
535
|
+
void copy(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
|
|
503
536
|
{
|
|
504
537
|
if (m_best_only)
|
|
505
|
-
|
|
538
|
+
{
|
|
539
|
+
if (!m_source->is_masked(ys, xs))
|
|
540
|
+
_best_copy(m_source->get_image(ys, xs), shift, *m_vote, yt, xt, best_weight);
|
|
541
|
+
}
|
|
506
542
|
else if (m_split)
|
|
507
|
-
split_copy(ys, xs, yt, xt, weight, best_weight);
|
|
543
|
+
split_copy(ys, xs, yt, xt, weight, best_weight, shift);
|
|
508
544
|
else
|
|
509
545
|
_weighted_copy(*m_source, ys, xs, *m_vote, yt, xt, weight);
|
|
510
546
|
}
|
|
511
547
|
|
|
512
|
-
// Adds the low frequencies and the texture of the source pixel to the sums, and keeps the rest if
|
|
513
|
-
// vote weighs most.
|
|
514
|
-
void split_copy(int ys, int xs, int yt, int xt, double weight, double best_weight) const
|
|
548
|
+
// Adds the shifted low frequencies and the texture of the source pixel to the sums, and keeps the rest if
|
|
549
|
+
// the vote weighs most.
|
|
550
|
+
void split_copy(int ys, int xs, int yt, int xt, double weight, double best_weight, const cv::Vec3d &shift) const
|
|
515
551
|
{
|
|
516
552
|
if (!is_known(*m_source, ys, xs))
|
|
517
553
|
return;
|
|
@@ -520,7 +556,7 @@ namespace
|
|
|
520
556
|
const auto *low = m_low.ptr<unsigned char>(ys, xs);
|
|
521
557
|
auto *vote = m_vote->ptr<double>(yt, xt);
|
|
522
558
|
for (int c = 0; c < 3; ++c)
|
|
523
|
-
vote[c] += low[c] * weight;
|
|
559
|
+
vote[c] += (low[c] + shift[c]) * weight;
|
|
524
560
|
vote[3] += weight;
|
|
525
561
|
vote[8] += m_source->texture().at<unsigned char>(ys, xs) * weight;
|
|
526
562
|
if (best_weight <= vote[7])
|
|
@@ -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
|
}
|
|
@@ -52,15 +52,16 @@ public:
|
|
|
52
52
|
{
|
|
53
53
|
return m_has_global_mask;
|
|
54
54
|
}
|
|
55
|
-
|
|
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
|
|
56
62
|
{
|
|
57
|
-
assert(
|
|
58
|
-
return
|
|
59
|
-
}
|
|
60
|
-
const cv::Mat &gradx() const
|
|
61
|
-
{
|
|
62
|
-
assert(m_image_grad_computed);
|
|
63
|
-
return m_image_gradx;
|
|
63
|
+
assert(m_features_computed);
|
|
64
|
+
return m_features;
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
// The texture energy that the distances compare (CV_8UC1, see Inpainting), or empty.
|
|
@@ -77,6 +78,7 @@ public:
|
|
|
77
78
|
{
|
|
78
79
|
m_texture = texture;
|
|
79
80
|
m_has_texture = !texture.empty();
|
|
81
|
+
m_features_computed = false;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
void init_global_mask_mat()
|
|
@@ -85,12 +87,14 @@ public:
|
|
|
85
87
|
m_global_mask.setTo(cv::Scalar(0));
|
|
86
88
|
m_has_global_mask = !m_global_mask.empty();
|
|
87
89
|
m_hole_sums_computed = false;
|
|
90
|
+
m_features_computed = false;
|
|
88
91
|
}
|
|
89
92
|
void set_global_mask_mat(const cv::Mat &other)
|
|
90
93
|
{
|
|
91
94
|
m_global_mask = other;
|
|
92
95
|
m_has_global_mask = !other.empty();
|
|
93
96
|
m_hole_sums_computed = false;
|
|
97
|
+
m_features_computed = false;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
bool is_masked(int y, int x) const
|
|
@@ -105,16 +109,19 @@ public:
|
|
|
105
109
|
{
|
|
106
110
|
m_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
|
|
107
111
|
m_hole_sums_computed = false;
|
|
112
|
+
m_features_computed = false;
|
|
108
113
|
}
|
|
109
114
|
void set_global_mask(int y, int x, bool value)
|
|
110
115
|
{
|
|
111
116
|
m_global_mask.at<unsigned char>(y, x) = static_cast<unsigned char>(value);
|
|
112
117
|
m_hole_sums_computed = false;
|
|
118
|
+
m_features_computed = false;
|
|
113
119
|
}
|
|
114
120
|
void clear_mask()
|
|
115
121
|
{
|
|
116
122
|
m_mask.setTo(cv::Scalar(0));
|
|
117
123
|
m_hole_sums_computed = false;
|
|
124
|
+
m_features_computed = false;
|
|
118
125
|
}
|
|
119
126
|
|
|
120
127
|
const unsigned char *get_image(int y, int x) const
|
|
@@ -130,7 +137,7 @@ public:
|
|
|
130
137
|
MaskedImage downsample() const;
|
|
131
138
|
MaskedImage upsample(int new_w, int new_h) const;
|
|
132
139
|
MaskedImage upsample(int new_w, int new_h, const cv::Mat &new_global_mask) const;
|
|
133
|
-
void
|
|
140
|
+
void compute_features() const;
|
|
134
141
|
|
|
135
142
|
private:
|
|
136
143
|
cv::Mat m_image;
|
|
@@ -140,10 +147,9 @@ private:
|
|
|
140
147
|
// cv::Mat::empty() is not inline in OpenCV 5, too slow to call for every pixel
|
|
141
148
|
bool m_has_global_mask = false;
|
|
142
149
|
bool m_has_texture = false;
|
|
143
|
-
// computed on first use by
|
|
144
|
-
mutable cv::Mat
|
|
145
|
-
mutable
|
|
146
|
-
mutable bool m_image_grad_computed = false;
|
|
150
|
+
// computed on first use by compute_features
|
|
151
|
+
mutable cv::Mat m_features;
|
|
152
|
+
mutable bool m_features_computed = false;
|
|
147
153
|
// computed on first use by contains_mask: the masked pixels outside the global mask
|
|
148
154
|
// above and left of each pixel. Copies share them with the masks, so change a mask only
|
|
149
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>
|
|
@@ -68,66 +69,44 @@ namespace
|
|
|
68
69
|
bool textured;
|
|
69
70
|
};
|
|
70
71
|
|
|
71
|
-
// 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.
|
|
72
73
|
class RowPair
|
|
73
74
|
{
|
|
74
75
|
public:
|
|
75
76
|
RowPair(const ImagePair &images, int ys, int yt)
|
|
76
77
|
: m_source_width(images.source_size.width), m_target_width(images.target_size.width),
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
m_sm(images.source->mask().ptr<unsigned char>(ys, 0)),
|
|
80
|
-
m_tm(images.target->mask().ptr<unsigned char>(yt, 0)),
|
|
81
|
-
m_sgm(
|
|
82
|
-
images.source->has_global_mask() ? images.source->global_mask().ptr<unsigned char>(ys, 0) : nullptr),
|
|
83
|
-
m_tgm(
|
|
84
|
-
images.target->has_global_mask() ? images.target->global_mask().ptr<unsigned char>(yt, 0) : nullptr),
|
|
85
|
-
m_sgy(images.source->grady().ptr<unsigned char>(ys, 0)),
|
|
86
|
-
m_tgy(images.target->grady().ptr<unsigned char>(yt, 0)),
|
|
87
|
-
m_sgx(images.source->gradx().ptr<unsigned char>(ys, 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)
|
|
78
|
+
m_source(images.source->features().ptr<unsigned char>(ys)),
|
|
79
|
+
m_target(images.target->features().ptr<unsigned char>(yt)), m_textured(images.textured)
|
|
91
80
|
{
|
|
92
81
|
}
|
|
93
82
|
|
|
94
83
|
// The SSD over the colors and gradients, kSSDScale where a pixel is masked or outside its image.
|
|
95
84
|
int cost(int xs, int xt) const
|
|
96
85
|
{
|
|
97
|
-
// The bounds first: the
|
|
98
|
-
if (xs < 0 || xs >= m_source_width || xt < 0 || xt >= m_target_width
|
|
99
|
-
|
|
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])
|
|
100
92
|
return PatchSSDDistanceMetric::kSSDScale;
|
|
101
93
|
|
|
102
94
|
int ssd = 0;
|
|
103
|
-
for (int
|
|
104
|
-
|
|
105
|
-
ssd += pow2(m_si[xs * 3 + c] - m_ti[xt * 3 + c]);
|
|
106
|
-
ssd += pow2(m_sgx[xs * 3 + c] - m_tgx[xt * 3 + c]);
|
|
107
|
-
ssd += pow2(m_sgy[xs * 3 + c] - m_tgy[xt * 3 + c]);
|
|
108
|
-
}
|
|
95
|
+
for (int k = 0; k < MaskedImage::kColorAndGradientFeatures; ++k)
|
|
96
|
+
ssd += pow2(s[k] - t[k]);
|
|
109
97
|
// Smooth fills matched smooth sources and stayed flat, so a target pixel with less than half the
|
|
110
98
|
// texture of its source pixel costs extra. Not a two-sided difference: it removed mortar joints.
|
|
111
|
-
if (
|
|
112
|
-
ssd += 2 * pow2(std::max(
|
|
99
|
+
if (m_textured)
|
|
100
|
+
ssd += 2 * pow2(std::max(s[MaskedImage::kTextureFeature] / 2 - t[MaskedImage::kTextureFeature], 0));
|
|
113
101
|
return ssd;
|
|
114
102
|
}
|
|
115
103
|
|
|
116
104
|
private:
|
|
117
105
|
int m_source_width;
|
|
118
106
|
int m_target_width;
|
|
119
|
-
const unsigned char *
|
|
120
|
-
const unsigned char *
|
|
121
|
-
|
|
122
|
-
const unsigned char *m_tm;
|
|
123
|
-
const unsigned char *m_sgm;
|
|
124
|
-
const unsigned char *m_tgm;
|
|
125
|
-
const unsigned char *m_sgy;
|
|
126
|
-
const unsigned char *m_tgy;
|
|
127
|
-
const unsigned char *m_sgx;
|
|
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;
|
|
107
|
+
const unsigned char *m_source;
|
|
108
|
+
const unsigned char *m_target;
|
|
109
|
+
bool m_textured; // false unless both images have a texture
|
|
131
110
|
};
|
|
132
111
|
|
|
133
112
|
// The costs of the row of the patches around s and t.
|
|
@@ -168,8 +147,8 @@ namespace
|
|
|
168
147
|
const double limit =
|
|
169
148
|
(bound + 1.0) * PatchSSDDistanceMetric::kSSDScale * side * side / PatchDistanceMetric::kDistanceScale;
|
|
170
149
|
|
|
171
|
-
source.
|
|
172
|
-
target.
|
|
150
|
+
source.compute_features();
|
|
151
|
+
target.compute_features();
|
|
173
152
|
|
|
174
153
|
const ImagePair images(source, target);
|
|
175
154
|
for (int dy = -patch_size; dy <= patch_size; ++dy)
|
|
@@ -60,6 +60,7 @@ metadata.readme.provider = "scikit_build_core.metadata.fancy_pypi_readme"
|
|
|
60
60
|
# The release wheels are built from the sdist, so it has to contain the tests
|
|
61
61
|
# and their input images; _version.py is generated and therefore git-ignored.
|
|
62
62
|
sdist.include = [
|
|
63
|
+
"examples/images/camera_aged.png",
|
|
63
64
|
"examples/images/camera_damaged.png",
|
|
64
65
|
"examples/images/camera_damaged_mask.png",
|
|
65
66
|
"examples/images/chelsea.png",
|
|
@@ -19,12 +19,15 @@ Usage:
|
|
|
19
19
|
uv run python scripts/evaluate_inpainting.py --baseline main/libpatchmatch.dylib
|
|
20
20
|
uv run python scripts/evaluate_inpainting.py --patch-size 15 --seeds 1 --cases brick
|
|
21
21
|
|
|
22
|
+
The inpaintings run in parallel processes, one per CPU unless --jobs says otherwise.
|
|
23
|
+
They slow each other down, so time a speed-up with --jobs 1.
|
|
24
|
+
|
|
22
25
|
--markdown prints the table as Markdown and moves the plain one to stderr; the CI
|
|
23
26
|
workflow "Fill quality" redirects it to the job summary.
|
|
24
27
|
|
|
25
28
|
Besides the table, it writes examples/images/evaluation.html (git-ignored): the holes,
|
|
26
|
-
the fills (of --baseline too) and the originals side by side
|
|
27
|
-
examples/images/local/ as well, so keep it out of the repository.
|
|
29
|
+
the fills (of --baseline too) and the originals side by side, cut to the holes. It
|
|
30
|
+
embeds the images of examples/images/local/ as well, so keep it out of the repository.
|
|
28
31
|
|
|
29
32
|
The e2e tests use measure() to check the seam and the detail of forest_pruned.bmp.
|
|
30
33
|
|
|
@@ -34,7 +37,15 @@ Columns, means over the seeds:
|
|
|
34
37
|
detail color difference of neighbors inside the holes, relative to their
|
|
35
38
|
surroundings; 0.85 to 1.1 in the original images, lower is blurrier
|
|
36
39
|
error mean color error against the original on images reduced to 1/8, which
|
|
37
|
-
compares the structure of the fill rather than its pixels
|
|
40
|
+
compares the structure of the fill rather than its pixels; it rates a flat
|
|
41
|
+
fill better than a texture whose structures are shifted
|
|
42
|
+
edges difference of the gradient directions from the original, in histograms
|
|
43
|
+
weighted by magnitude per 32x32 cell of the holes; lowest for the right
|
|
44
|
+
texture, also when shifted, highest for a flat fill
|
|
45
|
+
variety difference of the 8x8 patches of the holes, without their mean color, to
|
|
46
|
+
the closest one that does not overlap them, relative to the original;
|
|
47
|
+
lower where the fill repeats its sources or blurs (the tiles of
|
|
48
|
+
grass-large: about 0.25), higher for fragments
|
|
38
49
|
fills with --baseline: whether the fills of all seeds are byte-identical to the
|
|
39
50
|
baseline's, as they have to be after a change that only makes it faster
|
|
40
51
|
"""
|
|
@@ -43,18 +54,20 @@ from __future__ import annotations
|
|
|
43
54
|
|
|
44
55
|
import argparse
|
|
45
56
|
import base64
|
|
46
|
-
import
|
|
57
|
+
import functools
|
|
47
58
|
import hashlib
|
|
48
59
|
import html
|
|
49
60
|
import io
|
|
50
61
|
import sys
|
|
51
62
|
import time
|
|
52
63
|
from collections.abc import Iterator
|
|
64
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
53
65
|
from datetime import datetime
|
|
54
66
|
from pathlib import Path
|
|
55
67
|
from typing import NamedTuple, TextIO
|
|
56
68
|
|
|
57
69
|
import numpy as np
|
|
70
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
58
71
|
from PIL import Image
|
|
59
72
|
|
|
60
73
|
import patchmatch
|
|
@@ -73,7 +86,7 @@ HOLES = {
|
|
|
73
86
|
"chelsea": (52, 215, 24, 40),
|
|
74
87
|
"pattern": (320, 320, 40, 40),
|
|
75
88
|
}
|
|
76
|
-
COLUMNS = ("seam", "detail", "error")
|
|
89
|
+
COLUMNS = ("seam", "detail", "error", "edges", "variety")
|
|
77
90
|
|
|
78
91
|
|
|
79
92
|
class Case(NamedTuple):
|
|
@@ -182,6 +195,55 @@ def coarse_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> flo
|
|
|
182
195
|
return float(np.abs(reduce(image)[cells] - reduce(truth)[cells]).mean())
|
|
183
196
|
|
|
184
197
|
|
|
198
|
+
EDGE_CELL = 32
|
|
199
|
+
EDGE_BINS = 8
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def edge_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
|
|
203
|
+
"""Difference of the gradient directions, weighted by magnitude, per cell of holes."""
|
|
204
|
+
ys, xs = np.nonzero(holes)
|
|
205
|
+
cells = (ys // EDGE_CELL) * (holes.shape[1] // EDGE_CELL + 1) + xs // EDGE_CELL
|
|
206
|
+
length = (int(cells.max()) + 1) * EDGE_BINS
|
|
207
|
+
|
|
208
|
+
def histograms(array: np.ndarray) -> np.ndarray:
|
|
209
|
+
gy, gx = np.gradient(array.astype(float).mean(axis=2))
|
|
210
|
+
bins = (np.arctan2(gy, gx) % np.pi * EDGE_BINS / np.pi).astype(int) % EDGE_BINS
|
|
211
|
+
weights = np.hypot(gx, gy)[holes]
|
|
212
|
+
return np.bincount(cells * EDGE_BINS + bins[holes], weights, length)
|
|
213
|
+
|
|
214
|
+
return float(np.abs(histograms(image) - histograms(truth)).sum() / len(ys))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
PATCH = 8
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def patches(image: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
221
|
+
"""The PATCHxPATCH patches inside ``mask`` on a grid, flattened, and their corners."""
|
|
222
|
+
grid = (slice(None, None, PATCH // 2),) * 2
|
|
223
|
+
windows = sliding_window_view(image, (PATCH, PATCH), axis=(0, 1))[grid]
|
|
224
|
+
inside = sliding_window_view(mask, (PATCH, PATCH))[grid].all(axis=(2, 3))
|
|
225
|
+
flat = windows[inside].reshape(int(inside.sum()), 3, -1).astype(np.float32)
|
|
226
|
+
# without their mean color, which the votes shift for repeated sources too
|
|
227
|
+
flat -= flat.mean(axis=2, keepdims=True)
|
|
228
|
+
return flat.reshape(len(flat), -1), np.argwhere(inside) * (PATCH // 2)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def variety(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float:
|
|
232
|
+
def mean(array: np.ndarray) -> float:
|
|
233
|
+
"""Mean distance of the patches to the closest one that does not overlap them."""
|
|
234
|
+
found, corners = patches(array, holes)
|
|
235
|
+
norms = (found**2).sum(axis=1)
|
|
236
|
+
closest = []
|
|
237
|
+
for start in range(0, len(found), 512): # bounds the memory of the distances
|
|
238
|
+
chunk, where = found[start : start + 512], corners[start : start + 512]
|
|
239
|
+
distances = norms[start : start + 512, None] + norms - 2 * chunk @ found.T
|
|
240
|
+
distances[np.abs(where[:, None] - corners).max(axis=2) < PATCH] = np.inf
|
|
241
|
+
closest.append(distances.min(axis=1))
|
|
242
|
+
return float(np.sqrt(np.maximum(np.concatenate(closest), 0)).mean())
|
|
243
|
+
|
|
244
|
+
return mean(image) / mean(truth)
|
|
245
|
+
|
|
246
|
+
|
|
185
247
|
def measure(
|
|
186
248
|
result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
|
|
187
249
|
) -> dict[str, float]:
|
|
@@ -202,26 +264,40 @@ def measure(
|
|
|
202
264
|
}
|
|
203
265
|
if truth is not None:
|
|
204
266
|
values["error"] = coarse_error(result, truth, holes)
|
|
267
|
+
values["edges"] = edge_error(result, truth, holes)
|
|
268
|
+
# holes thinner than a patch, such as scratches, have none to compare
|
|
269
|
+
if sliding_window_view(holes, (PATCH, PATCH)).all(axis=(2, 3)).any():
|
|
270
|
+
values["variety"] = variety(result, truth, holes)
|
|
205
271
|
return values
|
|
206
272
|
|
|
207
273
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
274
|
+
Seed = tuple[np.ndarray, dict[str, float], float] # fill, measures, seconds
|
|
275
|
+
|
|
276
|
+
library = functools.cache(_lib.load_library)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def fill_seed(path: str, case: Case, patch_size: int, seed: int) -> Seed:
|
|
280
|
+
"""One inpainting, in a worker process."""
|
|
281
|
+
# processes, not threads: the seed and patch_match._lib are global
|
|
282
|
+
patch_match._lib = library(Path(path))
|
|
283
|
+
patchmatch.set_random_seed(seed)
|
|
212
284
|
start = time.perf_counter()
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
285
|
+
fill = patchmatch.inpaint(
|
|
286
|
+
case.image,
|
|
287
|
+
case.holes.astype(np.uint8),
|
|
288
|
+
global_mask=case.global_mask,
|
|
289
|
+
patch_size=patch_size,
|
|
290
|
+
)
|
|
291
|
+
seconds = time.perf_counter() - start
|
|
292
|
+
return fill, measure(fill, case.holes, case.truth), seconds
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def evaluate(jobs: list[Future[Seed]]) -> Result:
|
|
296
|
+
"""The result of one case and library from the jobs of all seeds, in order."""
|
|
297
|
+
fills, rows, seconds = zip(*(job.result() for job in jobs), strict=True)
|
|
222
298
|
values = {key: float(np.mean([row[key] for row in rows])) for key in rows[0]}
|
|
223
299
|
digest = hashlib.sha256(b"".join(fill.tobytes() for fill in fills)).hexdigest()
|
|
224
|
-
return Result(values, seconds, fills[0], digest)
|
|
300
|
+
return Result(values, float(np.mean(seconds)), fills[0], digest)
|
|
225
301
|
|
|
226
302
|
|
|
227
303
|
def identical(results: list[Result]) -> str:
|
|
@@ -286,19 +362,13 @@ figure { margin: 0; cursor: zoom-in; }
|
|
|
286
362
|
figcaption { font-size: 13px; color: var(--muted); margin-top: 4px;
|
|
287
363
|
font-variant-numeric: tabular-nums; }
|
|
288
364
|
figcaption b { color: var(--fg); font-weight: 600; margin-right: 6px; }
|
|
289
|
-
|
|
290
|
-
aspect-ratio: var(--crop); background: var(--line); }
|
|
291
|
-
.frame img { position: absolute; display: block; max-width: none; height: auto;
|
|
292
|
-
left: var(--left); top: var(--top); width: var(--width); }
|
|
293
|
-
body.whole .frame { aspect-ratio: var(--full); }
|
|
294
|
-
body.whole .frame img { left: 0; top: 0; width: 100%; }
|
|
365
|
+
figure img { display: block; width: 100%; height: auto; }
|
|
295
366
|
body.pixels img { image-rendering: pixelated; }
|
|
296
367
|
#viewer { position: fixed; inset: 0; z-index: 10; display: flex;
|
|
297
368
|
flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
|
298
369
|
background: rgb(0 0 0 / 0.9); cursor: zoom-out; }
|
|
299
370
|
#viewer[hidden] { display: none; }
|
|
300
|
-
#viewer
|
|
301
|
-
body.whole #viewer .frame { width: min(94vw, calc(86vh * var(--full))); }
|
|
371
|
+
#viewer img { width: 94vw; height: 86vh; object-fit: contain; cursor: pointer; }
|
|
302
372
|
#viewer p { color: #ddd; font-size: 14px; margin: 0; }
|
|
303
373
|
"""
|
|
304
374
|
|
|
@@ -310,7 +380,7 @@ function show(i) {
|
|
|
310
380
|
index = (i + figures.length) % figures.length;
|
|
311
381
|
const figure = figures[index];
|
|
312
382
|
viewer.querySelector(".slot").replaceChildren(
|
|
313
|
-
figure.querySelector("
|
|
383
|
+
figure.querySelector("img").cloneNode());
|
|
314
384
|
viewer.querySelector("p").textContent = figure.dataset.title
|
|
315
385
|
+ " (" + (index + 1) + "/" + figures.length + ")";
|
|
316
386
|
}
|
|
@@ -322,7 +392,7 @@ for (const figure of document.querySelectorAll("figure")) {
|
|
|
322
392
|
});
|
|
323
393
|
}
|
|
324
394
|
viewer.addEventListener("click", (event) => {
|
|
325
|
-
if (event.target.closest("
|
|
395
|
+
if (event.target.closest("img")) show(index + 1);
|
|
326
396
|
else viewer.hidden = true;
|
|
327
397
|
});
|
|
328
398
|
document.addEventListener("keydown", (event) => {
|
|
@@ -333,12 +403,10 @@ document.addEventListener("keydown", (event) => {
|
|
|
333
403
|
else return;
|
|
334
404
|
event.preventDefault();
|
|
335
405
|
});
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
apply(); // browsers restore the checkboxes on reload
|
|
341
|
-
}
|
|
406
|
+
const pixels = document.getElementById("pixels");
|
|
407
|
+
const apply = () => document.body.classList.toggle("pixels", pixels.checked);
|
|
408
|
+
pixels.addEventListener("change", apply);
|
|
409
|
+
apply(); // browsers restore the checkbox on reload
|
|
342
410
|
"""
|
|
343
411
|
|
|
344
412
|
|
|
@@ -362,23 +430,11 @@ def crop_box(holes: np.ndarray) -> tuple[int, int, int, int]:
|
|
|
362
430
|
return int(x0), int(y0), int(x1 - x0), int(y1 - y0)
|
|
363
431
|
|
|
364
432
|
|
|
365
|
-
def frame(image: np.ndarray, box: tuple[int, int, int, int]) -> str:
|
|
366
|
-
"""The image, cut to ``box`` by CSS unless the whole images are shown."""
|
|
367
|
-
height, width = image.shape[:2]
|
|
368
|
-
x, y, w, h = box
|
|
369
|
-
style = (
|
|
370
|
-
f"--crop:{w / h:.5f};--full:{width / height:.5f};--width:{100 * width / w:.4f}%;"
|
|
371
|
-
f"--left:{-100 * x / w:.4f}%;--top:{-100 * y / h:.4f}%"
|
|
372
|
-
)
|
|
373
|
-
source = f"data:image/png;base64,{png(image)}"
|
|
374
|
-
return f'<div class="frame" style="{style}"><img alt="" src="{source}"></div>'
|
|
375
|
-
|
|
376
|
-
|
|
377
433
|
def verdict(key: str, old: float, new: float) -> str:
|
|
378
434
|
"""CSS class of ``new`` compared with ``old``: better, worse or none."""
|
|
379
435
|
if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
|
|
380
436
|
return ""
|
|
381
|
-
if key in ("seam", "detail"): # best at 1.0, as in the original images
|
|
437
|
+
if key in ("seam", "detail", "variety"): # best at 1.0, as in the original images
|
|
382
438
|
old, new = abs(old - 1), abs(new - 1)
|
|
383
439
|
return "better" if new < old else "worse"
|
|
384
440
|
|
|
@@ -412,7 +468,7 @@ def caption(label: str, result: Result | None) -> str:
|
|
|
412
468
|
|
|
413
469
|
|
|
414
470
|
def section(case: Case, results: list[Result], labels: list[str]) -> str:
|
|
415
|
-
|
|
471
|
+
x, y, w, h = crop_box(case.holes)
|
|
416
472
|
shown = case.image.copy()
|
|
417
473
|
if case.global_mask is not None:
|
|
418
474
|
shown[case.global_mask] //= 2
|
|
@@ -422,7 +478,8 @@ def section(case: Case, results: list[Result], labels: list[str]) -> str:
|
|
|
422
478
|
versions.append(("original", case.truth, None))
|
|
423
479
|
figures = "".join(
|
|
424
480
|
f'<figure data-title="{html.escape(case.name)}: {label}">'
|
|
425
|
-
f"{
|
|
481
|
+
f'<img alt="" src="data:image/png;base64,{png(image[y : y + h, x : x + w])}">'
|
|
482
|
+
f"<figcaption>{caption(label, result)}</figcaption></figure>"
|
|
426
483
|
for label, image, result in versions
|
|
427
484
|
)
|
|
428
485
|
height, width = case.holes.shape
|
|
@@ -456,7 +513,6 @@ def write_report(
|
|
|
456
513
|
f"<title>patchmatch evaluation</title><style>{STYLE}</style></head><body>"
|
|
457
514
|
f"<main><header><h1>patchmatch evaluation</h1>{meta}</header>"
|
|
458
515
|
'<div class="controls">'
|
|
459
|
-
'<label><input type="checkbox" id="whole"> whole images</label>'
|
|
460
516
|
'<label><input type="checkbox" id="pixels"> show pixels</label>'
|
|
461
517
|
'<p class="hint">Click an image to enlarge it; click it, ← or → '
|
|
462
518
|
"switches between the versions, Esc closes.</p></div>"
|
|
@@ -498,9 +554,9 @@ def markdown(rows: list[tuple[Case, list[Result]]], title: str) -> str:
|
|
|
498
554
|
]
|
|
499
555
|
lines += [
|
|
500
556
|
"",
|
|
501
|
-
"Means over the seeds. seam and
|
|
502
|
-
"images, error at 0. With a baseline: baseline → current,
|
|
503
|
-
"by more than 2 % (time: 10 %).",
|
|
557
|
+
"Means over the seeds. seam, detail and variety are best at 1.0, as in the "
|
|
558
|
+
"original images, error and edges at 0. With a baseline: baseline → current, "
|
|
559
|
+
"🟢 better and 🔴 worse by more than 2 % (time: 10 %).",
|
|
504
560
|
]
|
|
505
561
|
return "\n".join(lines)
|
|
506
562
|
|
|
@@ -521,6 +577,7 @@ def main() -> None:
|
|
|
521
577
|
parser.add_argument("--patch-size", type=int, default=3)
|
|
522
578
|
parser.add_argument("--seeds", type=int, default=3)
|
|
523
579
|
parser.add_argument("--cases", help="comma-separated names, default: all")
|
|
580
|
+
parser.add_argument("--jobs", type=int, help="processes, default: one per CPU")
|
|
524
581
|
parser.add_argument(
|
|
525
582
|
"--markdown", action="store_true", help="print the table as Markdown"
|
|
526
583
|
)
|
|
@@ -537,24 +594,38 @@ def main() -> None:
|
|
|
537
594
|
if args.baseline:
|
|
538
595
|
libraries.insert(0, _lib.load_library(args.baseline.resolve()))
|
|
539
596
|
labels = ["baseline", "current"]
|
|
597
|
+
paths = [lib._name for lib in libraries]
|
|
540
598
|
selected = set(args.cases.split(",")) if args.cases else None
|
|
599
|
+
chosen = [case for case in cases() if not selected or case.name in selected]
|
|
541
600
|
|
|
542
601
|
out = sys.stderr if args.markdown else sys.stdout
|
|
543
602
|
print(f"patch_size={args.patch_size} seeds={args.seeds}", file=out)
|
|
544
603
|
fills = "fills" if args.baseline else ""
|
|
545
|
-
header = f"{'case':14} {'
|
|
604
|
+
header = f"{'case':14} {''.join(f'{key:16}' for key in COLUMNS)}{'time':16}{fills}"
|
|
546
605
|
print(header.rstrip(), file=out)
|
|
547
606
|
rows = []
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
607
|
+
pool = ProcessPoolExecutor(args.jobs)
|
|
608
|
+
try:
|
|
609
|
+
# all at once, the seeds outside, so that the libraries run under equal load
|
|
610
|
+
jobs = [
|
|
611
|
+
{
|
|
612
|
+
(index, seed): pool.submit(fill_seed, path, case, args.patch_size, seed)
|
|
613
|
+
for seed in range(args.seeds)
|
|
614
|
+
for index, path in enumerate(paths)
|
|
615
|
+
}
|
|
616
|
+
for case in chosen
|
|
553
617
|
]
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
618
|
+
for case, futures in zip(chosen, jobs, strict=True):
|
|
619
|
+
results = [
|
|
620
|
+
evaluate([futures[index, seed] for seed in range(args.seeds)])
|
|
621
|
+
for index in range(len(paths))
|
|
622
|
+
]
|
|
623
|
+
print_row(case.name, results, out)
|
|
624
|
+
rows.append((case, results))
|
|
625
|
+
finally:
|
|
626
|
+
pool.shutdown(cancel_futures=True) # Ctrl+C stops after the running jobs
|
|
627
|
+
|
|
628
|
+
meta = describe(args, labels, paths)
|
|
558
629
|
write_report(rows, labels, meta)
|
|
559
630
|
print(f"report: {REPORT}", file=out)
|
|
560
631
|
if args.markdown:
|
|
@@ -137,13 +137,21 @@ def test_global_mask_is_not_used_as_source(pruned_image):
|
|
|
137
137
|
|
|
138
138
|
|
|
139
139
|
def test_restore_aged_photo():
|
|
140
|
-
"""Scratches, holes and a torn corner, light but not white, given as a mask.
|
|
140
|
+
"""Scratches, holes and a torn corner, light but not white, given as a mask.
|
|
141
|
+
|
|
142
|
+
The finest levels averaged the colors of sources from elsewhere in the vignetted
|
|
143
|
+
photo, so the torn corner came out lighter (error 4.6 to 6.1 with ten seeds).
|
|
144
|
+
"""
|
|
141
145
|
source = Image.open(IMAGES / "camera_damaged.png")
|
|
142
146
|
mask = Image.open(IMAGES / "camera_damaged_mask.png")
|
|
147
|
+
holes = np.array(mask) > 0
|
|
143
148
|
|
|
144
149
|
result = patchmatch.inpaint(source, mask, patch_size=3)
|
|
145
150
|
|
|
146
|
-
assert_plausible_fill(np.array(source), result, holes=
|
|
151
|
+
assert_plausible_fill(np.array(source), result, holes=holes)
|
|
152
|
+
truth = evaluation.load(IMAGES / "camera_aged.png")
|
|
153
|
+
error = evaluation.coarse_error(result, truth, holes)
|
|
154
|
+
assert error < 4.2, error
|
|
147
155
|
|
|
148
156
|
|
|
149
157
|
@pytest.mark.parametrize(
|
|
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
|
|
File without changes
|