PyPatchMatch 2.0.1__tar.gz → 2.0.2__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.
Files changed (28) hide show
  1. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/CLAUDE.md +7 -4
  2. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/PKG-INFO +8 -7
  3. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/README.md +6 -5
  4. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/_version.py +3 -3
  5. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/masked_image.cpp +4 -2
  6. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/nnf.cpp +9 -4
  7. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/patch_match.py +33 -9
  8. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/tests/test_patch_match.py +104 -12
  9. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/CMakeLists.txt +0 -0
  10. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/LICENSE +0 -0
  11. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/SECURITY.md +0 -0
  12. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/examples/images/forest_pruned.bmp +0 -0
  13. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/__init__.py +0 -0
  14. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/_lib.py +0 -0
  15. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/inpaint.cpp +0 -0
  16. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/inpaint.h +0 -0
  17. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/masked_image.h +0 -0
  18. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/nnf.h +0 -0
  19. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/pyinterface.cpp +0 -0
  20. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/csrc/pyinterface.h +0 -0
  21. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/patchmatch/py.typed +0 -0
  22. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/pyproject.toml +0 -0
  23. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/scripts/build_opencv.py +0 -0
  24. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/scripts/evaluate_inpainting.py +0 -0
  25. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/scripts/render_readme_example.py +0 -0
  26. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/tests/conftest.py +0 -0
  27. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/tests/test_e2e.py +0 -0
  28. {pypatchmatch-2.0.1 → pypatchmatch-2.0.2}/tests/test_properties.py +0 -0
@@ -84,10 +84,13 @@ Other entry points:
84
84
  every name in `patch_match.__all__` is re-exported.
85
85
  2. `patchmatch/patch_match.py` is the public API. It validates and canonicalizes the
86
86
  inputs (`_prepare_inputs`, `_check_patch_size`, the `ijmap` and `guide_weight`
87
- checks), picks the entry point (`PM_inpaint`, `PM_inpaint2` with a global mask, and
88
- their `_regularity` variants) and runs it through `_call`. It loads the library at
89
- import time; on `OSError` it logs a warning and sets `patchmatch_available = False`,
90
- and every function then raises `RuntimeError` through `_get_lib()`.
87
+ checks), returns a copy of the image without a native call if `_has_holes` finds no
88
+ hole outside the global mask, picks the entry point (`PM_inpaint`, `PM_inpaint2`
89
+ with a global mask, and their `_regularity` variants) and runs it through `_call`.
90
+ It loads the library at import time; on `OSError` (no library) or `AttributeError`
91
+ (one without the `PM_*` symbols) it logs a warning and sets
92
+ `patchmatch_available = False`, and every function then raises `RuntimeError`
93
+ through `_get_lib()`.
91
94
  3. `patchmatch/_lib.py` is the ctypes layer: the `CShapeT`/`CMatT` structures, the dtype
92
95
  table, `np_to_pymat`/`pymat_to_np`, `find_library` (looks for `libpatchmatch.so`,
93
96
  `libpatchmatch.dylib` or `patchmatch.dll` in every directory of `patchmatch.__path__`)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyPatchMatch
3
- Version: 2.0.1
3
+ Version: 2.0.2
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>
@@ -42,7 +42,7 @@ and Python interfaces. This implementation is heavily based on the implementatio
42
42
  Younesse ANDAM: [younesse-cv/PatchMatch](https://github.com/younesse-cv/PatchMatch),
43
43
  with some bug fixes, and updates.
44
44
 
45
- ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.0.1/examples/images/readme_example.jpg)
45
+ ![Birdhouses removed from a photo by patchmatch.inpaint](https://raw.githubusercontent.com/mauwii/PyPatchMatch/v2.0.2/examples/images/readme_example.jpg)
46
46
 
47
47
  From left to right: the photo, the input of
48
48
  [examples/py_example.py](https://github.com/mauwii/PyPatchMatch/blob/main/examples/py_example.py)
@@ -99,11 +99,12 @@ The previous import path `from patchmatch import patch_match` keeps working.
99
99
  pixels in each direction. Larger patches follow larger structures but are much slower;
100
100
  the default of 15 compares 31x31 patches, the examples use 3.
101
101
 
102
- `patchmatch.set_random_seed(seed)` sets the seed of the randomized search and
103
- `patchmatch.set_verbose(True)` prints the progress of the native code to stderr.
104
- `patchmatch.inpaint_regularity(image, mask, ijmap)` additionally guides the search with
105
- a regularity map, an HxWx3 float32 array with the regularity coordinates of each pixel
106
- in its first two channels. Its `guide_weight` must be at least 0.
102
+ `patchmatch.set_random_seed(seed)` sets the seed of the randomized search, modulo
103
+ `2**32`, and `patchmatch.set_verbose(True)` prints the progress of the native code to
104
+ stderr. `patchmatch.inpaint_regularity(image, mask, ijmap)` additionally guides the
105
+ search with a regularity map, an HxWx3 float32 array with the regularity coordinates of
106
+ each pixel in its first two channels. Its `guide_weight` must be between 0 and the
107
+ largest float32, about 3.4e38.
107
108
 
108
109
  C++ (see
109
110
  [examples/cpp_example.cpp](https://github.com/mauwii/PyPatchMatch/blob/main/examples/cpp_example.cpp),
@@ -69,11 +69,12 @@ The previous import path `from patchmatch import patch_match` keeps working.
69
69
  pixels in each direction. Larger patches follow larger structures but are much slower;
70
70
  the default of 15 compares 31x31 patches, the examples use 3.
71
71
 
72
- `patchmatch.set_random_seed(seed)` sets the seed of the randomized search and
73
- `patchmatch.set_verbose(True)` prints the progress of the native code to stderr.
74
- `patchmatch.inpaint_regularity(image, mask, ijmap)` additionally guides the search with
75
- a regularity map, an HxWx3 float32 array with the regularity coordinates of each pixel
76
- in its first two channels. Its `guide_weight` must be at least 0.
72
+ `patchmatch.set_random_seed(seed)` sets the seed of the randomized search, modulo
73
+ `2**32`, and `patchmatch.set_verbose(True)` prints the progress of the native code to
74
+ stderr. `patchmatch.inpaint_regularity(image, mask, ijmap)` additionally guides the
75
+ search with a regularity map, an HxWx3 float32 array with the regularity coordinates of
76
+ each pixel in its first two channels. Its `guide_weight` must be between 0 and the
77
+ largest float32, about 3.4e38.
77
78
 
78
79
  C++ (see
79
80
  [examples/cpp_example.cpp](https://github.com/mauwii/PyPatchMatch/blob/main/examples/cpp_example.cpp),
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.0.1'
22
- __version_tuple__ = version_tuple = (2, 0, 1)
21
+ __version__ = version = '2.0.2'
22
+ __version_tuple__ = version_tuple = (2, 0, 2)
23
23
 
24
- __commit_id__ = commit_id = 'g995b76749'
24
+ __commit_id__ = commit_id = 'g38b611eb9'
@@ -1,6 +1,7 @@
1
1
  #include "masked_image.h"
2
2
 
3
3
  #include <algorithm>
4
+ #include <cstdint>
4
5
 
5
6
  const cv::Size MaskedImage::kDownsampleKernelSize = cv::Size(6, 6);
6
7
  const std::array<int, 6> MaskedImage::kDownsampleKernel = {1, 5, 10, 10, 5, 1};
@@ -107,8 +108,9 @@ MaskedImage MaskedImage::upsample(int new_w, int new_h) const
107
108
  {
108
109
  for (int x = 0; x < new_w; ++x)
109
110
  {
110
- const int yy = y * size.height / new_h;
111
- const int xx = x * size.width / new_w;
111
+ // In 64 bits: the products overflow an int from 65,537 pixels on.
112
+ const auto yy = static_cast<int>(std::int64_t{y} * size.height / new_h);
113
+ const auto xx = static_cast<int>(std::int64_t{x} * size.width / new_w);
112
114
 
113
115
  if (is_globally_masked(yy, xx))
114
116
  {
@@ -296,12 +296,17 @@ int RegularityGuidedPatchDistanceMetricV2::operator()(
296
296
  if (target_y < 0 || target_y >= target.size().height || target_x < 0 || target_x >= target.size().width)
297
297
  return PatchDistanceMetric::kDistanceScale;
298
298
 
299
- // Map pyramid-level coordinates to the full-resolution ijmap. Height and width are
300
- // scaled independently and clamped, since the pyramid sizes are rounded per axis.
299
+ // Map pyramid-level coordinates to the full-resolution ijmap, height and width
300
+ // independently; a coordinate inside the image stays inside the map. In 64 bits:
301
+ // the products overflow an int from 46,342 pixels on.
301
302
  const int map_h = m_ijmap.size().height;
302
303
  const int map_w = m_ijmap.size().width;
303
- auto map_y = [&](const MaskedImage &img, int y) { return std::min(y * map_h / img.size().height, map_h - 1); };
304
- auto map_x = [&](const MaskedImage &img, int x) { return std::min(x * map_w / img.size().width, map_w - 1); };
304
+ auto map_y = [&](const MaskedImage &img, int y) {
305
+ return static_cast<int>(std::int64_t{y} * map_h / img.size().height);
306
+ };
307
+ auto map_x = [&](const MaskedImage &img, int x) {
308
+ return static_cast<int>(std::int64_t{x} * map_w / img.size().width);
309
+ };
305
310
 
306
311
  double score1 = PatchDistanceMetric::kDistanceScale;
307
312
  if (!source.is_globally_masked(source_y, source_x) && !target.is_globally_masked(target_y, target_x))
@@ -17,7 +17,6 @@ from __future__ import annotations
17
17
 
18
18
  import ctypes
19
19
  import logging
20
- import math
21
20
  import operator
22
21
  from collections.abc import Callable
23
22
  from typing import SupportsIndex, TypeAlias
@@ -44,7 +43,9 @@ ImageLike: TypeAlias = np.ndarray | Image.Image
44
43
 
45
44
  try:
46
45
  _lib: ctypes.CDLL | None = load_library()
47
- except OSError as e:
46
+ except (OSError, AttributeError) as e:
47
+ # AttributeError: a library without the PM_* functions, e.g. one that 1.x
48
+ # compiled into the package directory and that pip does not remove on upgrade
48
49
  _lib = None
49
50
  logger.warning("patchmatch failed to load: %s", e)
50
51
 
@@ -54,6 +55,9 @@ patchmatch_available = _lib is not None
54
55
  # The native code computes 2 * patch_size + 1 as a C int.
55
56
  _MAX_PATCH_SIZE = (2**31 - 2) // 2
56
57
 
58
+ # The native code takes a C float, which turns larger weights into infinity.
59
+ _MAX_GUIDE_WEIGHT = float(np.finfo(np.float32).max)
60
+
57
61
 
58
62
  def _get_lib() -> ctypes.CDLL:
59
63
  if _lib is None:
@@ -62,10 +66,17 @@ def _get_lib() -> ctypes.CDLL:
62
66
 
63
67
 
64
68
  def set_random_seed(seed: int) -> None:
69
+ """Set the seed of the randomized search of all following inpaintings.
70
+
71
+ Every inpainting starts from this seed, so equal inputs give equal results. The
72
+ native code keeps it as a 32-bit unsigned int, so ``seed`` is taken modulo
73
+ ``2**32``: ``2**32`` behaves like 0 and -1 like ``2**32 - 1``.
74
+ """
65
75
  _get_lib().PM_set_random_seed(ctypes.c_uint(seed))
66
76
 
67
77
 
68
78
  def set_verbose(verbose: bool) -> None:
79
+ """Print the progress of all following inpaintings to stderr if ``verbose``."""
69
80
  _get_lib().PM_set_verbose(ctypes.c_int(verbose))
70
81
 
71
82
 
@@ -96,8 +107,7 @@ def inpaint(
96
107
  lib = _get_lib()
97
108
  patch_size = _check_patch_size(patch_size)
98
109
  image, mask, global_mask = _prepare_inputs(image, mask, global_mask)
99
- # OpenCV 4.6 turns an empty image into one of shape (0, 0, 1)
100
- if image.size == 0:
110
+ if not _has_holes(mask, global_mask):
101
111
  return image.copy()
102
112
 
103
113
  if global_mask is None:
@@ -140,15 +150,16 @@ def inpaint_regularity(
140
150
  raise ValueError("ijmap must only contain finite values")
141
151
  ijmap = np.ascontiguousarray(ijmap)
142
152
 
143
- # the native code divides by 1 + guide_weight and indexes a table with the result
153
+ # the native code divides by 1 + guide_weight and indexes a table with the result;
154
+ # the comparison also rejects NaN
144
155
  guide_weight = float(guide_weight)
145
- if not (math.isfinite(guide_weight) and guide_weight >= 0):
156
+ if not 0 <= guide_weight <= _MAX_GUIDE_WEIGHT:
146
157
  raise ValueError(
147
- f"guide_weight must be a finite number >= 0, got {guide_weight}"
158
+ f"guide_weight must be between 0 and {_MAX_GUIDE_WEIGHT:g}, "
159
+ f"got {guide_weight}"
148
160
  )
149
161
 
150
- # OpenCV 4.6 turns an empty image into one of shape (0, 0, 1)
151
- if image.size == 0:
162
+ if not _has_holes(mask, global_mask):
152
163
  return image.copy()
153
164
 
154
165
  args = (ijmap, ctypes.c_int(patch_size), ctypes.c_float(guide_weight))
@@ -194,6 +205,19 @@ def _prepare_inputs(
194
205
  return image, mask, global_mask
195
206
 
196
207
 
208
+ def _has_holes(mask: np.ndarray, global_mask: np.ndarray | None) -> bool:
209
+ """Whether a hole lies outside the global mask.
210
+
211
+ Otherwise the result equals the input, but the native code still runs every
212
+ level of the pyramid. This also covers empty images, which OpenCV 4.6 would
213
+ return with the shape (0, 0, 1).
214
+ """
215
+ holes = mask != 0
216
+ if global_mask is not None:
217
+ holes &= global_mask == 0
218
+ return bool(holes.any())
219
+
220
+
197
221
  def _call(func: Callable[..., CMatT], *args: object) -> np.ndarray:
198
222
  """Call ``func`` with arrays converted to pymats and copy the result."""
199
223
  lib = _get_lib()
@@ -14,6 +14,7 @@ from patchmatch import _lib, patch_match
14
14
 
15
15
  HEIGHT, WIDTH = 48, 64
16
16
  HOLE = (slice(20, 28), slice(24, 36))
17
+ AROUND_HOLE = (slice(14, 34), slice(18, 42))
17
18
 
18
19
 
19
20
  @pytest.fixture
@@ -27,6 +28,21 @@ def image() -> np.ndarray:
27
28
  return img
28
29
 
29
30
 
31
+ @pytest.fixture
32
+ def regular_image() -> np.ndarray:
33
+ """Noisy pattern that repeats every 8 pixels, as ijmap describes, with the hole.
34
+
35
+ The regularity term copies from whole periods away, which in the gradient of
36
+ ``image`` shifted the fill by up to 35 color values.
37
+ """
38
+ rng = np.random.default_rng(0)
39
+ tile = rng.integers(80, 120, (8, 8, 3))
40
+ noise = rng.integers(0, 8, (HEIGHT, WIDTH, 3))
41
+ img = (np.tile(tile, (HEIGHT // 8, WIDTH // 8, 1)) + noise).astype(np.uint8)
42
+ img[HOLE] = 255
43
+ return img
44
+
45
+
30
46
  @pytest.fixture
31
47
  def hole_mask() -> np.ndarray:
32
48
  mask = np.zeros((HEIGHT, WIDTH), dtype=np.uint8)
@@ -48,14 +64,21 @@ def seed():
48
64
 
49
65
 
50
66
  def assert_filled(result: np.ndarray, source: np.ndarray) -> None:
51
- """The hole is filled and all other pixels keep their values."""
67
+ """The hole is filled to blend in, and all other pixels keep their values."""
52
68
  assert result.shape == source.shape
53
69
  assert result.dtype == np.uint8
54
- assert not (result[HOLE] == 255).all(axis=-1).any()
55
70
  known = np.ones(source.shape[:2], dtype=bool)
56
71
  known[HOLE] = False
57
72
  np.testing.assert_array_equal(result[known], source[known])
58
73
 
74
+ # a hole that is not filled stays black, see _initialize_pyramid
75
+ ring = np.zeros_like(known)
76
+ ring[AROUND_HOLE] = True
77
+ ring[HOLE] = False
78
+ fill = result[HOLE].reshape(-1, 3).mean(axis=0)
79
+ color_diff = np.abs(fill - source[ring].mean(axis=0))
80
+ assert color_diff.max() < 10, color_diff
81
+
59
82
 
60
83
  # --- package ----------------------------------------------------------------
61
84
 
@@ -83,11 +106,7 @@ def test_inpaint_default_mask_fills_white_pixels(image):
83
106
 
84
107
  def test_inpaint_explicit_mask(image, hole_mask):
85
108
  image[HOLE] = 0 # not white: only the explicit mask marks the hole
86
- result = patchmatch.inpaint(image, hole_mask, patch_size=3)
87
- assert result.shape == image.shape
88
- assert (result[HOLE] != 0).any()
89
- known = hole_mask == 0
90
- np.testing.assert_array_equal(result[known], image[known])
109
+ assert_filled(patchmatch.inpaint(image, hole_mask, patch_size=3), image)
91
110
 
92
111
 
93
112
  def test_inpaint_global_mask(image, hole_mask):
@@ -127,6 +146,33 @@ def test_inpaint_empty_image(shape, ijmap):
127
146
  assert result is not empty
128
147
 
129
148
 
149
+ @pytest.mark.parametrize("covered", [False, True], ids=["no-hole", "global-mask"])
150
+ @pytest.mark.parametrize(
151
+ "func",
152
+ [patchmatch.inpaint, patchmatch.inpaint_regularity],
153
+ ids=lambda f: f.__name__,
154
+ )
155
+ def test_input_without_holes_skips_the_native_code(
156
+ monkeypatch, image, hole_mask, ijmap, func, covered
157
+ ):
158
+ """The result equals the input, but the native code ran every level.
159
+
160
+ That took 14 s for a 512 x 512 image with the default patch size.
161
+ """
162
+
163
+ def native_call(*args):
164
+ pytest.fail("the native code was called")
165
+
166
+ monkeypatch.setattr(patch_match, "_call", native_call)
167
+ mask = hole_mask if covered else np.zeros_like(hole_mask)
168
+ kwargs = {"ijmap": ijmap} if func is patchmatch.inpaint_regularity else {}
169
+
170
+ result = func(image, mask, global_mask=hole_mask if covered else None, **kwargs)
171
+
172
+ np.testing.assert_array_equal(result, image)
173
+ assert not np.shares_memory(result, image)
174
+
175
+
130
176
  @pytest.mark.parametrize("patch_size", [1, 3, 7])
131
177
  def test_inpaint_patch_sizes(image, patch_size):
132
178
  assert_filled(patchmatch.inpaint(image, patch_size=patch_size), image)
@@ -154,6 +200,30 @@ def test_inpaint_thin_hole_takes_the_colors_beside_it():
154
200
  assert np.abs(fill - plain).max() < 10, (seed, fill)
155
201
 
156
202
 
203
+ @pytest.mark.parametrize("regularity", [False, True], ids=["inpaint", "regularity"])
204
+ @pytest.mark.parametrize("tall", [True, False], ids=["tall", "wide"])
205
+ def test_inpaint_very_large_image(tall, regularity):
206
+ """Coordinate products overflowed a C int near the end of very large images.
207
+
208
+ The regularity metric crashed from 46,342 pixels on, and the upsampling filled
209
+ a hole near the bottom black from 65,537 pixels on.
210
+ """
211
+ image = np.random.default_rng(0).integers(90, 110, (70_000, 4, 3), dtype=np.uint8)
212
+ image[-30:-20, 1:3] = 255
213
+ if not tall:
214
+ image = image.transpose(1, 0, 2)
215
+ holes = (image == 255).all(axis=2)
216
+
217
+ if regularity:
218
+ ijmap = np.zeros(image.shape, dtype=np.float32)
219
+ result = patchmatch.inpaint_regularity(image, None, ijmap, patch_size=1)
220
+ else:
221
+ result = patchmatch.inpaint(image, patch_size=1)
222
+
223
+ np.testing.assert_array_equal(result[~holes], image[~holes])
224
+ assert np.abs(result[holes].mean(axis=0) - 100).max() < 10
225
+
226
+
157
227
  def test_inpaint_does_not_modify_inputs(image, hole_mask):
158
228
  image_before, mask_before = image.copy(), hole_mask.copy()
159
229
  patchmatch.inpaint(image, hole_mask, global_mask=hole_mask, patch_size=3)
@@ -205,19 +275,19 @@ def test_set_verbose_prints_progress(verbose):
205
275
  @pytest.mark.parametrize("use_global_mask", [False, True], ids=["", "global"])
206
276
  @pytest.mark.parametrize("guide_weight", [0.0, 0.25, 1.0])
207
277
  def test_inpaint_regularity(
208
- image, hole_mask, ijmap, use_mask, use_global_mask, guide_weight
278
+ regular_image, hole_mask, ijmap, use_mask, use_global_mask, guide_weight
209
279
  ):
210
280
  global_mask = np.zeros_like(hole_mask)
211
281
  global_mask[:8] = 1
212
282
  result = patchmatch.inpaint_regularity(
213
- image,
283
+ regular_image,
214
284
  hole_mask if use_mask else None,
215
285
  ijmap,
216
286
  global_mask=global_mask if use_global_mask else None,
217
287
  patch_size=3,
218
288
  guide_weight=guide_weight,
219
289
  )
220
- assert_filled(result, image)
290
+ assert_filled(result, regular_image)
221
291
 
222
292
 
223
293
  def test_inpaint_regularity_scales_smaller_ijmap(image):
@@ -347,10 +417,10 @@ def test_inpaint_regularity_invalid_ijmap(image, bad_ijmap):
347
417
  patchmatch.inpaint_regularity(image, None, bad_ijmap)
348
418
 
349
419
 
350
- @pytest.mark.parametrize("guide_weight", [-1.0, -0.5, float("nan"), float("inf")])
420
+ @pytest.mark.parametrize("guide_weight", [-1.0, -0.5, float("nan"), float("inf"), 1e39])
351
421
  def test_inpaint_regularity_invalid_guide_weight(image, ijmap, guide_weight):
352
422
  # a weight of -1 used to crash the native code, other negative weights made it
353
- # read outside of the similarity table
423
+ # read outside of the similarity table, and 1e39 became infinity as a C float
354
424
  with pytest.raises(ValueError, match="guide_weight"):
355
425
  patchmatch.inpaint_regularity(image, None, ijmap, guide_weight=guide_weight)
356
426
 
@@ -447,6 +517,28 @@ def test_import_without_native_library():
447
517
  assert "--no-cache-dir" in process.stderr
448
518
 
449
519
 
520
+ def test_import_with_incompatible_library():
521
+ """A library without the PM_* functions must not break the import either.
522
+
523
+ 1.x compiled the library into the package directory, and pip keeps it there.
524
+ Pillow's extension module stands in for it.
525
+ """
526
+ code = (
527
+ "import importlib\n"
528
+ "from pathlib import Path\n"
529
+ "import PIL._imaging\n"
530
+ "from patchmatch import _lib, patch_match\n"
531
+ "_lib.find_library = lambda: Path(PIL._imaging.__file__)\n"
532
+ "importlib.reload(patch_match)\n"
533
+ "assert not patch_match.patchmatch_available\n"
534
+ )
535
+ process = subprocess.run(
536
+ [sys.executable, "-c", code], capture_output=True, text=True, check=True
537
+ )
538
+ assert "patchmatch failed to load" in process.stderr
539
+ assert "PM_" in process.stderr
540
+
541
+
450
542
  # --- ctypes conversion ------------------------------------------------------
451
543
 
452
544
 
File without changes
File without changes