PyPatchMatch 2.2.0b3__tar.gz → 2.2.0rc2__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.0b3 → pypatchmatch-2.2.0rc2}/CLAUDE.md +7 -3
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/PKG-INFO +14 -9
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/README.md +12 -7
- pypatchmatch-2.2.0rc2/examples/images/camera_aged.png +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/forest_pruned.bmp +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/_version.py +3 -3
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/inpaint.cpp +53 -17
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/pyproject.toml +5 -2
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/evaluate_inpainting.py +99 -32
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_e2e.py +10 -2
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/CMakeLists.txt +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/LICENSE +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/SECURITY.md +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/camera_damaged.png +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/camera_damaged_mask.png +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/examples/images/chelsea.png +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/__init__.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/_lib.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/inpaint.h +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/masked_image.cpp +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/masked_image.h +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/nnf.cpp +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/nnf.h +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/pyinterface.cpp +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/csrc/pyinterface.h +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/patch_match.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/patchmatch/py.typed +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/build_opencv.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/scripts/render_readme_example.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/conftest.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_patch_match.py +0 -0
- {pypatchmatch-2.2.0b3 → pypatchmatch-2.2.0rc2}/tests/test_properties.py +0 -0
|
@@ -102,8 +102,10 @@ CMake installs the shared library into the package directory.
|
|
|
102
102
|
members, `k` constants. Commit a reformat separately and add its hash to
|
|
103
103
|
`.git-blame-ignore-revs`.
|
|
104
104
|
- The package ships `py.typed`; public functions are fully annotated. mypy checks
|
|
105
|
-
`patchmatch` and `tests` in strict mode (`[tool.mypy]`); the
|
|
106
|
-
API correctly but need no annotations of their own.
|
|
105
|
+
`examples`, `patchmatch`, `scripts` and `tests` in strict mode (`[tool.mypy]`); the
|
|
106
|
+
tests have to use the API correctly but need no annotations of their own. Only the
|
|
107
|
+
examples annotate their locals and pass arguments by keyword, for readers who copy
|
|
108
|
+
them. A deliberately wrong argument
|
|
107
109
|
goes through a test parameter instead of a `# type: ignore`.
|
|
108
110
|
- Backwards compatibility: `from patchmatch import patch_match` and
|
|
109
111
|
`patch_match.CShapeT`/`CMatT` keep working (tested).
|
|
@@ -134,4 +136,6 @@ CMake installs the shared library into the package directory.
|
|
|
134
136
|
`invoke-ai/PyPatchMatch`. `gh` resolves the default repository to upstream, so pass
|
|
135
137
|
`-R mauwii/PyPatchMatch`.
|
|
136
138
|
- 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.
|
|
139
|
+
pre-releases) builds the wheels and publishes them to PyPI. The workflow `Fill
|
|
140
|
+
quality` compares the release with the last stable one; read its summary before
|
|
141
|
+
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.0rc2
|
|
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)
|
|
@@ -59,9 +64,9 @@ pip install PyPatchMatch
|
|
|
59
64
|
```
|
|
60
65
|
|
|
61
66
|
Wheels for Linux (glibc, x86_64 and aarch64), macOS 11+ (arm64 and x86_64) and Windows
|
|
62
|
-
(x64) ship the compiled library with a statically linked OpenCV, so no
|
|
63
|
-
OpenCV installation is needed. On other platforms, e.g. Alpine Linux
|
|
64
|
-
|
|
67
|
+
(x64 and ARM64) ship the compiled library with a statically linked OpenCV, so no
|
|
68
|
+
compiler or OpenCV installation is needed. On other platforms, e.g. Alpine Linux, pip
|
|
69
|
+
builds from the source distribution, which requires a C++17 compiler, CMake and the
|
|
65
70
|
OpenCV development files (e.g. `apt install libopencv-dev` or `brew install opencv`).
|
|
66
71
|
|
|
67
72
|
If that build fails, PyPatchMatch is installed without the native library instead of
|
|
@@ -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
|
|
@@ -29,9 +34,9 @@ pip install PyPatchMatch
|
|
|
29
34
|
```
|
|
30
35
|
|
|
31
36
|
Wheels for Linux (glibc, x86_64 and aarch64), macOS 11+ (arm64 and x86_64) and Windows
|
|
32
|
-
(x64) ship the compiled library with a statically linked OpenCV, so no
|
|
33
|
-
OpenCV installation is needed. On other platforms, e.g. Alpine Linux
|
|
34
|
-
|
|
37
|
+
(x64 and ARM64) ship the compiled library with a statically linked OpenCV, so no
|
|
38
|
+
compiler or OpenCV installation is needed. On other platforms, e.g. Alpine Linux, pip
|
|
39
|
+
builds from the source distribution, which requires a C++17 compiler, CMake and the
|
|
35
40
|
OpenCV development files (e.g. `apt install libopencv-dev` or `brew install opencv`).
|
|
36
41
|
|
|
37
42
|
If that build fails, PyPatchMatch is installed without the native library instead of
|
|
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.0rc2'
|
|
22
|
+
__version_tuple__ = version_tuple = (2, 2, 0, 'rc2')
|
|
23
23
|
|
|
24
|
-
__commit_id__ = commit_id = '
|
|
24
|
+
__commit_id__ = commit_id = 'gd6affc5f5'
|
|
@@ -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])
|
|
@@ -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",
|
|
@@ -119,6 +120,8 @@ environment = { OpenCV_ROOT = "/tmp/opencv", MACOSX_DEPLOYMENT_TARGET = "11.0" }
|
|
|
119
120
|
environment = { OpenCV_ROOT = "C:/opencv" }
|
|
120
121
|
|
|
121
122
|
[tool.uv]
|
|
123
|
+
# the cooldown of dependabot.yml for uv lock --upgrade
|
|
124
|
+
exclude-newer = "7 days"
|
|
122
125
|
# Rebuild the package whenever the native sources or the git version change.
|
|
123
126
|
cache-keys = [
|
|
124
127
|
{ file = "CMakeLists.txt" },
|
|
@@ -130,7 +133,6 @@ cache-keys = [
|
|
|
130
133
|
[tool.ruff]
|
|
131
134
|
extend-exclude = ["patchmatch/csrc"]
|
|
132
135
|
line-length = 88
|
|
133
|
-
target-version = "py311"
|
|
134
136
|
|
|
135
137
|
[tool.ruff.lint]
|
|
136
138
|
select = ["B", "C4", "E", "F", "I", "RUF", "SIM", "UP", "W"]
|
|
@@ -143,7 +145,7 @@ patch = ["subprocess"]
|
|
|
143
145
|
fail_under = 95
|
|
144
146
|
|
|
145
147
|
[tool.mypy]
|
|
146
|
-
files = ["patchmatch", "tests"]
|
|
148
|
+
files = ["examples", "patchmatch", "scripts", "tests"]
|
|
147
149
|
strict = true
|
|
148
150
|
|
|
149
151
|
[[tool.mypy.overrides]]
|
|
@@ -162,6 +164,7 @@ warn_return_any = false
|
|
|
162
164
|
addopts = ["--cov-branch", "--cov-report=term:skip-covered", "--cov=patchmatch"]
|
|
163
165
|
# shows which test hangs before the job timeout kills the run
|
|
164
166
|
faulthandler_timeout = 300
|
|
167
|
+
filterwarnings = ["error"]
|
|
165
168
|
markers = ["e2e: end-to-end tests of the user workflows (deselect with -m 'not e2e')"]
|
|
166
169
|
minversion = "9.0"
|
|
167
170
|
python_files = ["test_*.py"]
|
|
@@ -19,6 +19,9 @@ 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
|
|
|
@@ -39,6 +42,10 @@ Columns, means over the seeds:
|
|
|
39
42
|
edges difference of the gradient directions from the original, in histograms
|
|
40
43
|
weighted by magnitude per 32x32 cell of the holes; lowest for the right
|
|
41
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
|
|
42
49
|
fills with --baseline: whether the fills of all seeds are byte-identical to the
|
|
43
50
|
baseline's, as they have to be after a change that only makes it faster
|
|
44
51
|
"""
|
|
@@ -47,18 +54,20 @@ from __future__ import annotations
|
|
|
47
54
|
|
|
48
55
|
import argparse
|
|
49
56
|
import base64
|
|
50
|
-
import
|
|
57
|
+
import functools
|
|
51
58
|
import hashlib
|
|
52
59
|
import html
|
|
53
60
|
import io
|
|
54
61
|
import sys
|
|
55
62
|
import time
|
|
56
63
|
from collections.abc import Iterator
|
|
64
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
57
65
|
from datetime import datetime
|
|
58
66
|
from pathlib import Path
|
|
59
67
|
from typing import NamedTuple, TextIO
|
|
60
68
|
|
|
61
69
|
import numpy as np
|
|
70
|
+
from numpy.lib.stride_tricks import sliding_window_view
|
|
62
71
|
from PIL import Image
|
|
63
72
|
|
|
64
73
|
import patchmatch
|
|
@@ -77,7 +86,7 @@ HOLES = {
|
|
|
77
86
|
"chelsea": (52, 215, 24, 40),
|
|
78
87
|
"pattern": (320, 320, 40, 40),
|
|
79
88
|
}
|
|
80
|
-
COLUMNS = ("seam", "detail", "error", "edges")
|
|
89
|
+
COLUMNS = ("seam", "detail", "error", "edges", "variety")
|
|
81
90
|
|
|
82
91
|
|
|
83
92
|
class Case(NamedTuple):
|
|
@@ -100,12 +109,12 @@ def load(path: Path) -> np.ndarray:
|
|
|
100
109
|
|
|
101
110
|
|
|
102
111
|
def white(image: np.ndarray) -> np.ndarray:
|
|
103
|
-
return (image == 255
|
|
112
|
+
return np.all(image == 255, axis=2)
|
|
104
113
|
|
|
105
114
|
|
|
106
115
|
def ellipse(shape: tuple[int, ...], cy: int, cx: int, ry: int, rx: int) -> np.ndarray:
|
|
107
116
|
yy, xx = np.mgrid[: shape[0], : shape[1]]
|
|
108
|
-
return ((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 < 1
|
|
117
|
+
return np.asarray(((yy - cy) / ry) ** 2 + ((xx - cx) / rx) ** 2 < 1)
|
|
109
118
|
|
|
110
119
|
|
|
111
120
|
def cut(
|
|
@@ -205,6 +214,36 @@ def edge_error(image: np.ndarray, truth: np.ndarray, holes: np.ndarray) -> float
|
|
|
205
214
|
return float(np.abs(histograms(image) - histograms(truth)).sum() / len(ys))
|
|
206
215
|
|
|
207
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
|
+
|
|
208
247
|
def measure(
|
|
209
248
|
result: np.ndarray, holes: np.ndarray, truth: np.ndarray | None = None
|
|
210
249
|
) -> dict[str, float]:
|
|
@@ -226,26 +265,39 @@ def measure(
|
|
|
226
265
|
if truth is not None:
|
|
227
266
|
values["error"] = coarse_error(result, truth, holes)
|
|
228
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)
|
|
229
271
|
return values
|
|
230
272
|
|
|
231
273
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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)
|
|
236
284
|
start = time.perf_counter()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
246
298
|
values = {key: float(np.mean([row[key] for row in rows])) for key in rows[0]}
|
|
247
299
|
digest = hashlib.sha256(b"".join(fill.tobytes() for fill in fills)).hexdigest()
|
|
248
|
-
return Result(values, seconds, fills[0], digest)
|
|
300
|
+
return Result(values, float(np.mean(seconds)), fills[0], digest)
|
|
249
301
|
|
|
250
302
|
|
|
251
303
|
def identical(results: list[Result]) -> str:
|
|
@@ -382,7 +434,7 @@ def verdict(key: str, old: float, new: float) -> str:
|
|
|
382
434
|
"""CSS class of ``new`` compared with ``old``: better, worse or none."""
|
|
383
435
|
if abs(new - old) <= (0.1 if key == "time" else 0.02) * abs(old):
|
|
384
436
|
return ""
|
|
385
|
-
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
|
|
386
438
|
old, new = abs(old - 1), abs(new - 1)
|
|
387
439
|
return "better" if new < old else "worse"
|
|
388
440
|
|
|
@@ -420,7 +472,7 @@ def section(case: Case, results: list[Result], labels: list[str]) -> str:
|
|
|
420
472
|
shown = case.image.copy()
|
|
421
473
|
if case.global_mask is not None:
|
|
422
474
|
shown[case.global_mask] //= 2
|
|
423
|
-
versions = [("holes", shown, None)]
|
|
475
|
+
versions: list[tuple[str, np.ndarray, Result | None]] = [("holes", shown, None)]
|
|
424
476
|
versions += [(label, r.fill, r) for label, r in zip(labels, results, strict=True)]
|
|
425
477
|
if case.truth is not None:
|
|
426
478
|
versions.append(("original", case.truth, None))
|
|
@@ -502,9 +554,9 @@ def markdown(rows: list[tuple[Case, list[Result]]], title: str) -> str:
|
|
|
502
554
|
]
|
|
503
555
|
lines += [
|
|
504
556
|
"",
|
|
505
|
-
"Means over the seeds. seam and
|
|
506
|
-
"images, error and edges at 0. With a baseline: baseline → current,
|
|
507
|
-
"and 🔴 worse 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 %).",
|
|
508
560
|
]
|
|
509
561
|
return "\n".join(lines)
|
|
510
562
|
|
|
@@ -525,6 +577,7 @@ def main() -> None:
|
|
|
525
577
|
parser.add_argument("--patch-size", type=int, default=3)
|
|
526
578
|
parser.add_argument("--seeds", type=int, default=3)
|
|
527
579
|
parser.add_argument("--cases", help="comma-separated names, default: all")
|
|
580
|
+
parser.add_argument("--jobs", type=int, help="processes, default: one per CPU")
|
|
528
581
|
parser.add_argument(
|
|
529
582
|
"--markdown", action="store_true", help="print the table as Markdown"
|
|
530
583
|
)
|
|
@@ -541,7 +594,9 @@ def main() -> None:
|
|
|
541
594
|
if args.baseline:
|
|
542
595
|
libraries.insert(0, _lib.load_library(args.baseline.resolve()))
|
|
543
596
|
labels = ["baseline", "current"]
|
|
597
|
+
paths = [lib._name for lib in libraries]
|
|
544
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]
|
|
545
600
|
|
|
546
601
|
out = sys.stderr if args.markdown else sys.stdout
|
|
547
602
|
print(f"patch_size={args.patch_size} seeds={args.seeds}", file=out)
|
|
@@ -549,16 +604,28 @@ def main() -> None:
|
|
|
549
604
|
header = f"{'case':14} {''.join(f'{key:16}' for key in COLUMNS)}{'time':16}{fills}"
|
|
550
605
|
print(header.rstrip(), file=out)
|
|
551
606
|
rows = []
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
557
617
|
]
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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)
|
|
562
629
|
write_report(rows, labels, meta)
|
|
563
630
|
print(f"report: {REPORT}", file=out)
|
|
564
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|