object-remove 0.1.0__py3-none-any.whl

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 (36) hide show
  1. object_remove/__init__.py +37 -0
  2. object_remove/fillengines/__init__.py +20 -0
  3. object_remove/fillengines/patch_comparator.py +182 -0
  4. object_remove/fillengines/patchmatch/__init__.py +41 -0
  5. object_remove/fillengines/patchmatch/group_solver.py +127 -0
  6. object_remove/fillengines/patchmatch/patch.py +48 -0
  7. object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
  8. object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
  9. object_remove/fillengines/patchmatch/solver.py +152 -0
  10. object_remove/fillengines/self_similar_shift.py +368 -0
  11. object_remove/imageutil.py +59 -0
  12. object_remove/mask/__init__.py +22 -0
  13. object_remove/mask/auto_select.py +84 -0
  14. object_remove/mask/brush_rasterizer.py +97 -0
  15. object_remove/mask/connected_components.py +110 -0
  16. object_remove/mask/polygon_selection.py +219 -0
  17. object_remove/mask/selection_enhancer.py +127 -0
  18. object_remove/pipeline/__init__.py +7 -0
  19. object_remove/pipeline/remove_object_job.py +154 -0
  20. object_remove/py.typed +0 -0
  21. object_remove/pyramid/__init__.py +15 -0
  22. object_remove/pyramid/image_pyramid.py +101 -0
  23. object_remove/pyramid/scale_scheduler.py +56 -0
  24. object_remove/pyramid/tile_scheduler.py +80 -0
  25. object_remove/render/__init__.py +16 -0
  26. object_remove/render/compositor.py +62 -0
  27. object_remove/render/multiband_blender.py +75 -0
  28. object_remove/render/poisson_blender.py +132 -0
  29. object_remove/session/__init__.py +11 -0
  30. object_remove/session/empty_session_sweeper.py +49 -0
  31. object_remove/session/undo_session_manager.py +178 -0
  32. object_remove-0.1.0.dist-info/METADATA +130 -0
  33. object_remove-0.1.0.dist-info/RECORD +36 -0
  34. object_remove-0.1.0.dist-info/WHEEL +5 -0
  35. object_remove-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. object_remove-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,37 @@
1
+ """object_remove — a from scratch, CPU implementation of an on device object
2
+ removal pipeline.
3
+
4
+ See ``docs/ARCHITECTURE.md`` for the full design: a polygon/scanline mask
5
+ layer, three fill engines of increasing sophistication, two interchangeable
6
+ blend backends, and a locked/queued session persistence layer.
7
+
8
+ Public entry point::
9
+
10
+ from object_remove import RemoveObjectJob, EngineTier
11
+ job = RemoveObjectJob(image_rgb)
12
+ result = job.run(mask, tier=EngineTier.AUTO)
13
+ """
14
+
15
+ import importlib
16
+ from typing import Any
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ # Lazy attribute map: name maps to (submodule, attribute). Loaded on first access so
21
+ # importing any subpackage never eagerly drags in the whole tree.
22
+ _LAZY = {
23
+ "RemoveObjectJob": ("object_remove.pipeline.remove_object_job", "RemoveObjectJob"),
24
+ "EngineTier": ("object_remove.pipeline.remove_object_job", "EngineTier"),
25
+ "JobResult": ("object_remove.pipeline.remove_object_job", "JobResult"),
26
+ "PolygonSelection": ("object_remove.mask.polygon_selection", "PolygonSelection"),
27
+ "BrushRasterizer": ("object_remove.mask.brush_rasterizer", "BrushRasterizer"),
28
+ }
29
+
30
+ __all__ = list(_LAZY)
31
+
32
+
33
+ def __getattr__(name: str) -> Any:
34
+ if name in _LAZY:
35
+ module_name, attr = _LAZY[name]
36
+ return getattr(importlib.import_module(module_name), attr)
37
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,20 @@
1
+ """Stage C — the three fill engine tiers.
2
+
3
+ * Tier 1 :class:`SegmentAwarePatchFillEngine` — legacy CPU segment aware SSD.
4
+ * Tier 2 :class:`SelfSimilarShiftEngine` — ORB+BRIEF rigid shift + DP seam carve
5
+ (the fast default path for repetitive texture).
6
+ * Tier 3 :class:`PatchMatchEngine` — multi scale, multi group PatchMatch (quality).
7
+
8
+ Every engine exposes the same ``fill(image, mask, progress=None, cancel=None)``
9
+ signature so the pipeline can pick a tier without special casing.
10
+ """
11
+
12
+ from .patch_comparator import SegmentAwarePatchFillEngine
13
+ from .self_similar_shift import SelfSimilarShiftEngine
14
+ from .patchmatch import PatchMatchEngine
15
+
16
+ __all__ = [
17
+ "SegmentAwarePatchFillEngine",
18
+ "SelfSimilarShiftEngine",
19
+ "PatchMatchEngine",
20
+ ]
@@ -0,0 +1,182 @@
1
+ """Tier 1 — legacy CPU segment aware patch comparator.
2
+
3
+ Patch distance is SSD style but computed *within a segmentation zone
4
+ context*: candidates are penalised when they don't belong to the same segment
5
+ as the query, and validity is checked by scanning a per pixel byte mask rather
6
+ than a statistical flatness heuristic.
7
+
8
+ The engine on top is a greedy onion peel exemplar fill: cheap, correct on small
9
+ holes, and the natural CPU fallback tier for low end devices.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+ from typing import Optional, Tuple
16
+
17
+ import numpy as np
18
+
19
+ from ..imageutil import as_float_rgb, rgb_to_gray
20
+
21
+
22
+ class SegmentValidityMap:
23
+ """Validity + segment zone bookkeeping alongside the color image."""
24
+
25
+ def __init__(self, image: np.ndarray, valid: np.ndarray,
26
+ segments: Optional[np.ndarray] = None):
27
+ self.image = image
28
+ self.valid = valid # bool (H,W): True = usable source pixel
29
+ if segments is None:
30
+ segments = self._quantize_segments(image)
31
+ self.segments = segments
32
+
33
+ @staticmethod
34
+ def _quantize_segments(image: np.ndarray, n_zones: int = 6) -> np.ndarray:
35
+ """Cheap segmentation zones: coarse luma quantisation.
36
+
37
+ Stands in for a real segmentation map; good enough to make the
38
+ same segment gate meaningful (grass vs sky vs wall).
39
+ """
40
+ luma = rgb_to_gray(image)
41
+ return np.clip((luma * n_zones).astype(np.int32), 0, n_zones - 1)
42
+
43
+ def is_rect_opaque_safe(self, x: int, y: int, w: int, h: int) -> bool:
44
+ """Bounds check then scan the byte mask; false on the first invalid pixel."""
45
+ H, W = self.valid.shape
46
+ if x < 0 or y < 0 or x + w > W or y + h > H:
47
+ return False
48
+ return bool(self.valid[y:y + h, x:x + w].all())
49
+
50
+ def is_same_segment(self, y0: int, x0: int, y1: int, x1: int) -> bool:
51
+ return self.segments[y0, x0] == self.segments[y1, x1]
52
+
53
+
54
+ @dataclass
55
+ class PatchSegmentationComparator:
56
+ patch_radius: int = 3
57
+ segment_penalty: float = 0.25 # added to distance when segments differ
58
+
59
+ def patch_sqr_distance(self, seg: SegmentValidityMap,
60
+ target_y: int, target_x: int,
61
+ source_y: int, source_x: int,
62
+ target_valid: np.ndarray) -> float:
63
+ """Segment aware masked SSD between a target and a source patch center.
64
+
65
+ Only pixels valid in *both* the target neighbourhood (``target_valid``)
66
+ and the source are compared, matching the byte mask validity discipline.
67
+ """
68
+ r = self.patch_radius
69
+ H, W = seg.valid.shape
70
+ img = seg.image
71
+ acc = 0.0
72
+ valid_pair_count = 0
73
+ for dy in range(-r, r + 1):
74
+ ty, sy = target_y + dy, source_y + dy
75
+ if not (0 <= ty < H and 0 <= sy < H):
76
+ continue
77
+ for dx in range(-r, r + 1):
78
+ tx, sx = target_x + dx, source_x + dx
79
+ if not (0 <= tx < W and 0 <= sx < W):
80
+ continue
81
+ if not target_valid[ty, tx] or not seg.valid[sy, sx]:
82
+ continue
83
+ diff = img[ty, tx] - img[sy, sx]
84
+ acc += float(diff.dot(diff))
85
+ valid_pair_count += 1
86
+ if valid_pair_count == 0:
87
+ return float("inf")
88
+ dist = acc / valid_pair_count
89
+ if not seg.is_same_segment(target_y, target_x, source_y, source_x):
90
+ dist += self.segment_penalty
91
+ return dist
92
+
93
+
94
+ class SegmentAwarePatchFillEngine:
95
+ """Greedy onion peel exemplar fill using the comparator (tier 1)."""
96
+
97
+ def __init__(self, patch_radius: int = 3, search_stride: int = 2,
98
+ max_sources: int = 500, seed: int = 7):
99
+ self.comparator = PatchSegmentationComparator(patch_radius=patch_radius)
100
+ self.patch_radius = patch_radius
101
+ self.search_stride = search_stride
102
+ self.max_sources = max_sources
103
+ self.seed = seed
104
+
105
+ def fill(self, image: np.ndarray, hole: np.ndarray,
106
+ progress=None, cancel=None) -> np.ndarray:
107
+ img = as_float_rgb(image).astype(np.float64).copy()
108
+ hole = np.ascontiguousarray(hole > 0)
109
+ H, W = hole.shape
110
+ valid = ~hole
111
+ seg = SegmentValidityMap(img, valid.copy())
112
+
113
+ src_ys, src_xs = np.nonzero(valid)
114
+ src_pts = np.stack([src_ys, src_xs], axis=1)
115
+ src_pts = src_pts[::max(1, self.search_stride)]
116
+ if len(src_pts) > self.max_sources:
117
+ rng = np.random.default_rng(self.seed)
118
+ sel = rng.choice(len(src_pts), self.max_sources, replace=False)
119
+ src_pts = src_pts[sel]
120
+
121
+ remaining = int(hole.sum())
122
+ total = max(1, remaining)
123
+ filled_valid = valid.copy() # grows as we fill
124
+
125
+ while remaining > 0:
126
+ if cancel is not None and cancel():
127
+ break
128
+ border = self._boundary_pixels(hole)
129
+ if border.size == 0:
130
+ break
131
+ for (target_y, target_x) in border:
132
+ if not hole[target_y, target_x]:
133
+ continue
134
+ best = self._best_source(seg, filled_valid, target_y, target_x, src_pts)
135
+ if best is None:
136
+ img[target_y, target_x] = self._local_mean(img, filled_valid, target_y, target_x)
137
+ else:
138
+ source_y, source_x = best
139
+ img[target_y, target_x] = img[source_y, source_x]
140
+ hole[target_y, target_x] = False
141
+ filled_valid[target_y, target_x] = True
142
+ seg.valid[target_y, target_x] = True
143
+ remaining -= 1
144
+ if progress is not None and remaining % 64 == 0:
145
+ progress((total - remaining) / total)
146
+ if progress is not None:
147
+ progress(1.0)
148
+ return img
149
+
150
+ def _best_source(self, seg, target_valid, target_y, target_x, src_pts):
151
+ best_d = float("inf")
152
+ best = None
153
+ for (source_y, source_x) in src_pts:
154
+ d = self.comparator.patch_sqr_distance(
155
+ seg, target_y, target_x, source_y, source_x, target_valid)
156
+ if d < best_d:
157
+ best_d = d
158
+ best = (int(source_y), int(source_x))
159
+ return best
160
+
161
+ @staticmethod
162
+ def _boundary_pixels(hole: np.ndarray) -> np.ndarray:
163
+ """Hole pixels adjacent to a filled pixel (the fill front)."""
164
+ h = hole
165
+ front = np.zeros_like(h)
166
+ front[1:, :] |= h[1:, :] & ~h[:-1, :]
167
+ front[:-1, :] |= h[:-1, :] & ~h[1:, :]
168
+ front[:, 1:] |= h[:, 1:] & ~h[:, :-1]
169
+ front[:, :-1] |= h[:, :-1] & ~h[:, 1:]
170
+ ys, xs = np.nonzero(front)
171
+ return np.stack([ys, xs], axis=1)
172
+
173
+ @staticmethod
174
+ def _local_mean(img, valid, target_y, target_x, r=2):
175
+ H, W = valid.shape
176
+ y0, y1 = max(0, target_y - r), min(H, target_y + r + 1)
177
+ x0, x1 = max(0, target_x - r), min(W, target_x + r + 1)
178
+ block = img[y0:y1, x0:x1]
179
+ vmask = valid[y0:y1, x0:x1]
180
+ if vmask.any():
181
+ return block[vmask].mean(axis=0)
182
+ return img[target_y, target_x]
@@ -0,0 +1,41 @@
1
+ """Tier 3 — CPU PatchMatch fill engine.
2
+
3
+ Get classic CPU PatchMatch correct first; GPU resident nearest neighbour field
4
+ storage and learned feature preprocessing are natural follow on upgrades once
5
+ the CPU algorithm is solid. This package is that CPU stage.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .solver import PatchMatchSolver
13
+ from .group_solver import GroupSolver, multiscale_fill
14
+ from .patch import Patch, NearestNeighborField
15
+ from .patch_scorer import PatchScorer
16
+ from .patch_search_index import PatchSearchIndex
17
+
18
+ __all__ = [
19
+ "PatchMatchEngine", "PatchMatchSolver", "GroupSolver", "multiscale_fill",
20
+ "Patch", "NearestNeighborField", "PatchScorer", "PatchSearchIndex",
21
+ ]
22
+
23
+
24
+ class PatchMatchEngine:
25
+ """Public tier 3 engine: multi scale, multi group PatchMatch inpainting."""
26
+
27
+ def __init__(self, patch_radius: int = 3, pm_iters: int = 4,
28
+ em_iters: int = 4, levels: int = 4, seed: int = 1234,
29
+ isolate_groups: bool = True):
30
+ self.solver = PatchMatchSolver(radius=patch_radius, pm_iters=pm_iters,
31
+ em_iters=em_iters, seed=seed)
32
+ self.levels = levels
33
+ self.isolate_groups = isolate_groups
34
+
35
+ def fill(self, image: np.ndarray, mask: np.ndarray,
36
+ progress=None, cancel=None) -> np.ndarray:
37
+ if self.isolate_groups:
38
+ return GroupSolver(self.solver, self.levels).fill(
39
+ image, mask, progress=progress, cancel=cancel)
40
+ return multiscale_fill(image, mask > 0, mask > 0, self.solver,
41
+ self.levels, progress=progress, cancel=cancel)
@@ -0,0 +1,127 @@
1
+ """Multi scale driver + multi group isolation.
2
+
3
+ * Coarse to fine: solve a tiny problem on the coarsest pyramid level, upsample
4
+ the fill as the initialisation for the next finer level, repeat — this is what
5
+ lets PatchMatch propagate structure across a large hole.
6
+ * Multi group: each connected component is solved with the *other* components
7
+ held as invalid (unusable as source) so one object's pixels never leak into
8
+ another's fill — exactly the ``formQuickSelections`` /
9
+ ``separateObjectsFromSelection`` setup from Stage A.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+
16
+ from ...imageutil import as_float_rgb
17
+ from ...pyramid.image_pyramid import ImagePyramid, upsample, downsample
18
+ from ..patch_comparator import SegmentValidityMap # noqa: F401 (kept for symmetry)
19
+ from .solver import PatchMatchSolver
20
+
21
+
22
+ def _hole_pyramid(hole: np.ndarray, levels: int):
23
+ pyr = [hole.astype(bool)]
24
+ level_hole = hole.astype(np.float64)
25
+ for _ in range(levels - 1):
26
+ level_hole = downsample(level_hole)
27
+ pyr.append(level_hole > 0.4)
28
+ if min(level_hole.shape) < 2:
29
+ break
30
+ return pyr
31
+
32
+
33
+ def _pad(array: np.ndarray, r: int, value=0.0):
34
+ if array.ndim == 3:
35
+ return np.pad(array, ((r, r), (r, r), (0, 0)), mode="edge")
36
+ return np.pad(array, ((r, r), (r, r)), mode="constant", constant_values=value)
37
+
38
+
39
+ def multiscale_fill(image: np.ndarray, target_hole: np.ndarray,
40
+ invalid_hole: np.ndarray, solver: PatchMatchSolver,
41
+ levels: int, progress=None, cancel=None) -> np.ndarray:
42
+ """Fill ``target_hole`` coarse to fine; ``invalid_hole`` = pixels no source may use."""
43
+ r = solver.radius
44
+ img = as_float_rgb(image).astype(np.float64)
45
+ img_pyr = ImagePyramid(img, levels)
46
+ L = img_pyr.levels
47
+ target_hole_pyr = _hole_pyramid(target_hole, L)
48
+ invalid_pyr = _hole_pyramid(invalid_hole, L)
49
+
50
+ level_estimate = None
51
+ for li in range(L - 1, -1, -1):
52
+ if cancel is not None and cancel():
53
+ break
54
+ level_img = img_pyr.gaussian[li].copy()
55
+ target_hole_lvl = target_hole_pyr[li]
56
+ invalid_lvl = invalid_pyr[li]
57
+ if target_hole_lvl.shape != level_img.shape[:2]:
58
+ target_hole_lvl = _resize_mask(target_hole_lvl, level_img.shape[:2])
59
+ invalid_lvl = _resize_mask(invalid_lvl, level_img.shape[:2])
60
+
61
+ if level_estimate is None:
62
+ valid = ~invalid_lvl
63
+ seed = level_img[valid].mean(axis=0) if valid.any() else np.zeros(3)
64
+ level_img[invalid_lvl] = seed
65
+ else:
66
+ upsampled_estimate = upsample(level_estimate, level_img.shape[:2])
67
+ level_img[invalid_lvl] = upsampled_estimate[invalid_lvl]
68
+
69
+ padded_img = _pad(level_img, r)
70
+ padded_hole = _pad(target_hole_lvl, r, value=False).astype(bool)
71
+ padded_invalid = _pad(invalid_lvl, r, value=False).astype(bool)
72
+
73
+ def level_progress(p, li=li):
74
+ if progress is not None:
75
+ done = (L - 1 - li) + p
76
+ progress(min(1.0, done / L))
77
+
78
+ filled = solver.solve_level(padded_img, padded_hole, padded_invalid,
79
+ progress=level_progress, cancel=cancel)
80
+ level_estimate = filled[r:-r, r:-r] if r > 0 else filled
81
+
82
+ if progress is not None:
83
+ progress(1.0)
84
+ out = as_float_rgb(image).astype(np.float64).copy()
85
+ hole_mask = target_hole > 0
86
+ out[hole_mask] = level_estimate[hole_mask]
87
+ return out
88
+
89
+
90
+ def _resize_mask(mask: np.ndarray, shape) -> np.ndarray:
91
+ h, w = shape
92
+ ys = (np.linspace(0, mask.shape[0] - 1, h)).astype(int)
93
+ xs = (np.linspace(0, mask.shape[1] - 1, w)).astype(int)
94
+ return mask[np.ix_(ys, xs)]
95
+
96
+
97
+ class GroupSolver:
98
+ """Solve each connected component independently, others held invalid."""
99
+
100
+ def __init__(self, solver: PatchMatchSolver, levels: int = 4):
101
+ self.solver = solver
102
+ self.levels = levels
103
+
104
+ def fill(self, image: np.ndarray, mask: np.ndarray, components=None,
105
+ progress=None, cancel=None) -> np.ndarray:
106
+ from ...mask.connected_components import split_components
107
+
108
+ img = as_float_rgb(image).astype(np.float64).copy()
109
+ full_hole = mask > 0
110
+ if components is None:
111
+ components = split_components(full_hole)
112
+ if not components:
113
+ return img
114
+ n = len(components)
115
+ for i, comp in enumerate(components):
116
+ if cancel is not None and cancel():
117
+ break
118
+
119
+ def comp_progress(p, i=i):
120
+ if progress is not None:
121
+ progress((i + p) / n)
122
+
123
+ img = multiscale_fill(img, comp, full_hole, self.solver,
124
+ self.levels, progress=comp_progress, cancel=cancel)
125
+ # once a component is filled it becomes valid source for later ones
126
+ full_hole = full_hole & ~comp
127
+ return img
@@ -0,0 +1,48 @@
1
+ """Patch + nearest neighbour field data structures.
2
+
3
+ CPU first, for clarity and to keep the dependency footprint small. A GPU
4
+ version would store this same field in a render target texture instead of a
5
+ numpy array; the algorithm is identical.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ import numpy as np
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Patch:
17
+ """A square patch defined by its center and radius (in padded coords)."""
18
+
19
+ y: int
20
+ x: int
21
+ radius: int
22
+
23
+ @property
24
+ def size(self) -> int:
25
+ return 2 * self.radius + 1
26
+
27
+
28
+ class NearestNeighborField:
29
+ """For each target pixel, the source patch center it currently maps to.
30
+
31
+ ``coords[y, x] = (sy, sx)`` source center; ``dist[y, x]`` = patch distance.
32
+ All coordinates are in the *padded* frame used by the solver.
33
+ """
34
+
35
+ def __init__(self, height: int, width: int):
36
+ self.height = height
37
+ self.width = width
38
+ self.coords = np.zeros((height, width, 2), dtype=np.int32)
39
+ self.dist = np.full((height, width), np.inf, dtype=np.float64)
40
+
41
+ def set(self, y: int, x: int, sy: int, sx: int, d: float) -> None:
42
+ self.coords[y, x, 0] = sy
43
+ self.coords[y, x, 1] = sx
44
+ self.dist[y, x] = d
45
+
46
+ def source_of(self, y: int, x: int) -> tuple[int, int]:
47
+ c = self.coords[y, x]
48
+ return int(c[0]), int(c[1])
@@ -0,0 +1,38 @@
1
+ """Patch distance scoring.
2
+
3
+ Classic SSD on the current image estimate. A higher quality version would
4
+ replace this with a *learned* feature distance computed once up front: that
5
+ is a drop in swap here, precompute a feature image and point
6
+ :class:`PatchScorer` at it instead of the raw RGB. The search algorithm
7
+ doesn't change, which is exactly why learned features are a natural follow on
8
+ upgrade rather than a redesign.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+
15
+
16
+ class PatchScorer:
17
+ """Sum of squared differences over a fixed square window in padded coords."""
18
+
19
+ def __init__(self, radius: int = 3):
20
+ self.radius = radius
21
+
22
+ def distance(self, feat: np.ndarray, ty: int, tx: int,
23
+ sy: int, sx: int) -> float:
24
+ r = self.radius
25
+ a = feat[ty - r:ty + r + 1, tx - r:tx + r + 1]
26
+ b = feat[sy - r:sy + r + 1, sx - r:sx + r + 1]
27
+ d = a - b
28
+ return float(np.einsum("ijk,ijk->", d, d))
29
+
30
+ def distance_weighted(self, feat: np.ndarray, weight: np.ndarray,
31
+ ty: int, tx: int, sy: int, sx: int) -> float:
32
+ """SSD weighted per pixel (used to down weight still unknown pixels)."""
33
+ r = self.radius
34
+ a = feat[ty - r:ty + r + 1, tx - r:tx + r + 1]
35
+ b = feat[sy - r:sy + r + 1, sx - r:sx + r + 1]
36
+ w = weight[ty - r:ty + r + 1, tx - r:tx + r + 1]
37
+ d = (a - b)
38
+ return float(np.einsum("ij,ijk,ijk->", w, d, d))
@@ -0,0 +1,56 @@
1
+ """Valid source bookkeeping + sampling.
2
+
3
+ A source patch is usable only if its whole window is valid (outside every hole),
4
+ the byte mask scan discipline from tier 1, here precomputed for the entire image
5
+ via a box sum so per candidate validity is an O(1) lookup. Also provides uniform
6
+ random sampling over valid source centers for PatchMatch initialisation and
7
+ random search.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+
15
+ class PatchSearchIndex:
16
+ def __init__(self, invalid: np.ndarray, radius: int):
17
+ """``invalid`` (H, W) bool in *padded* coords: True = cannot be sourced from."""
18
+ self.radius = radius
19
+ self.height, self.width = invalid.shape
20
+ self.source_ok = self._compute_source_ok(invalid, radius)
21
+ ys, xs = np.nonzero(self.source_ok)
22
+ self._src_ys = ys
23
+ self._src_xs = xs
24
+ self.n_sources = len(ys)
25
+
26
+ @staticmethod
27
+ def _compute_source_ok(invalid: np.ndarray, r: int) -> np.ndarray:
28
+ """True where a full (2r+1) window contains zero invalid pixels."""
29
+ H, W = invalid.shape
30
+ integral = np.zeros((H + 1, W + 1), dtype=np.int64)
31
+ integral[1:, 1:] = np.cumsum(np.cumsum(invalid.astype(np.int64), 0), 1)
32
+
33
+ def box_sum(y0, y1, x0, x1):
34
+ return (integral[y1, x1] - integral[y0, x1]
35
+ - integral[y1, x0] + integral[y0, x0])
36
+
37
+ ok = np.zeros((H, W), dtype=bool)
38
+ for y in range(r, H - r):
39
+ y0, y1 = y - r, y + r + 1
40
+ row_ok = np.zeros(W, dtype=bool)
41
+ for x in range(r, W - r):
42
+ if box_sum(y0, y1, x - r, x + r + 1) == 0:
43
+ row_ok[x] = True
44
+ ok[y] = row_ok
45
+ return ok
46
+
47
+ def is_source_ok(self, sy: int, sx: int) -> bool:
48
+ if 0 <= sy < self.height and 0 <= sx < self.width:
49
+ return bool(self.source_ok[sy, sx])
50
+ return False
51
+
52
+ def sample(self, rng: np.random.Generator, n: int = 1):
53
+ if self.n_sources == 0:
54
+ return np.empty((0, 2), dtype=np.int32)
55
+ idx = rng.integers(0, self.n_sources, size=n)
56
+ return np.stack([self._src_ys[idx], self._src_xs[idx]], axis=1).astype(np.int32)