object-remove 0.1.0__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 (47) hide show
  1. object_remove-0.1.0/LICENSE +21 -0
  2. object_remove-0.1.0/PKG-INFO +130 -0
  3. object_remove-0.1.0/README.md +102 -0
  4. object_remove-0.1.0/object_remove/__init__.py +37 -0
  5. object_remove-0.1.0/object_remove/fillengines/__init__.py +20 -0
  6. object_remove-0.1.0/object_remove/fillengines/patch_comparator.py +182 -0
  7. object_remove-0.1.0/object_remove/fillengines/patchmatch/__init__.py +41 -0
  8. object_remove-0.1.0/object_remove/fillengines/patchmatch/group_solver.py +127 -0
  9. object_remove-0.1.0/object_remove/fillengines/patchmatch/patch.py +48 -0
  10. object_remove-0.1.0/object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
  11. object_remove-0.1.0/object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
  12. object_remove-0.1.0/object_remove/fillengines/patchmatch/solver.py +152 -0
  13. object_remove-0.1.0/object_remove/fillengines/self_similar_shift.py +368 -0
  14. object_remove-0.1.0/object_remove/imageutil.py +59 -0
  15. object_remove-0.1.0/object_remove/mask/__init__.py +22 -0
  16. object_remove-0.1.0/object_remove/mask/auto_select.py +84 -0
  17. object_remove-0.1.0/object_remove/mask/brush_rasterizer.py +97 -0
  18. object_remove-0.1.0/object_remove/mask/connected_components.py +110 -0
  19. object_remove-0.1.0/object_remove/mask/polygon_selection.py +219 -0
  20. object_remove-0.1.0/object_remove/mask/selection_enhancer.py +127 -0
  21. object_remove-0.1.0/object_remove/pipeline/__init__.py +7 -0
  22. object_remove-0.1.0/object_remove/pipeline/remove_object_job.py +154 -0
  23. object_remove-0.1.0/object_remove/py.typed +0 -0
  24. object_remove-0.1.0/object_remove/pyramid/__init__.py +15 -0
  25. object_remove-0.1.0/object_remove/pyramid/image_pyramid.py +101 -0
  26. object_remove-0.1.0/object_remove/pyramid/scale_scheduler.py +56 -0
  27. object_remove-0.1.0/object_remove/pyramid/tile_scheduler.py +80 -0
  28. object_remove-0.1.0/object_remove/render/__init__.py +16 -0
  29. object_remove-0.1.0/object_remove/render/compositor.py +62 -0
  30. object_remove-0.1.0/object_remove/render/multiband_blender.py +75 -0
  31. object_remove-0.1.0/object_remove/render/poisson_blender.py +132 -0
  32. object_remove-0.1.0/object_remove/session/__init__.py +11 -0
  33. object_remove-0.1.0/object_remove/session/empty_session_sweeper.py +49 -0
  34. object_remove-0.1.0/object_remove/session/undo_session_manager.py +178 -0
  35. object_remove-0.1.0/object_remove.egg-info/PKG-INFO +130 -0
  36. object_remove-0.1.0/object_remove.egg-info/SOURCES.txt +45 -0
  37. object_remove-0.1.0/object_remove.egg-info/dependency_links.txt +1 -0
  38. object_remove-0.1.0/object_remove.egg-info/requires.txt +6 -0
  39. object_remove-0.1.0/object_remove.egg-info/top_level.txt +1 -0
  40. object_remove-0.1.0/pyproject.toml +39 -0
  41. object_remove-0.1.0/setup.cfg +4 -0
  42. object_remove-0.1.0/tests/test_fillengines.py +70 -0
  43. object_remove-0.1.0/tests/test_mask.py +70 -0
  44. object_remove-0.1.0/tests/test_pipeline.py +61 -0
  45. object_remove-0.1.0/tests/test_pyramid.py +42 -0
  46. object_remove-0.1.0/tests/test_render.py +40 -0
  47. object_remove-0.1.0/tests/test_session.py +54 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shalaga44
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: object_remove
3
+ Version: 0.1.0
4
+ Summary: From-scratch CPU reference implementation of a tiered on-device object-removal pipeline
5
+ Author: shalaga44
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/shalaga44/object_remove
8
+ Project-URL: Issues, https://github.com/shalaga44/object_remove/issues
9
+ Keywords: image-processing,inpainting,object-removal,computer-vision
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Scientific/Engineering :: Image Processing
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Operating System :: OS Independent
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: numpy>=2.0
23
+ Requires-Dist: scipy>=1.10
24
+ Requires-Dist: Pillow>=9.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # object_remove
30
+
31
+ A from scratch, CPU implementation of an on device object removal pipeline (see
32
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
33
+ polygon/scanline mask layer, **three fill engines** of increasing
34
+ sophistication, **two interchangeable blend backends**, and a locked/queued
35
+ session persistence layer.
36
+
37
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
38
+ Hamming matching, PatchMatch, Laplacian/Poisson blending) — no OpenCV, no learned
39
+ weights.
40
+
41
+ ## Quick start
42
+
43
+ ```bash
44
+ pip install -r requirements.txt # numpy, scipy, Pillow
45
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
46
+ PYTHONPATH=. python -m pytest -q # 31 tests
47
+ ```
48
+
49
+ ```python
50
+ import numpy as np
51
+ from object_remove import RemoveObjectJob, EngineTier
52
+
53
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
54
+ progress=lambda stage, frac: ...)
55
+ removed = result.image # float32 RGB in [0, 1]
56
+ print(result.tier_used) # which engine AUTO picked
57
+ ```
58
+
59
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
60
+ nonzero marking the object to remove.
61
+
62
+ ## Module layout
63
+
64
+ | Stage | Concern | This repo |
65
+ |---|---|---|
66
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
67
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
68
+ | C1 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
69
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
70
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
71
+ | E — blend | multi band + Poisson | `object_remove/render/` |
72
+ | F — session | undo / persistence | `object_remove/session/` |
73
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
74
+
75
+ ## The three fill engines
76
+
77
+ - **Tier 1 — `SegmentAwarePatchFillEngine`** — greedy onion peel exemplar fill
78
+ scored by segment aware SSD with byte mask validity. Cheap, correct on small
79
+ holes; the low end / tiny hole fallback.
80
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
81
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
82
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
83
+ a transpose trick.
84
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
85
+ (random init → propagation → random search, then patch voting reconstruction,
86
+ coarse to fine). The quality path.
87
+
88
+ `EngineTier.AUTO` sends truly tiny holes to tier 1, otherwise tries the cheap
89
+ tier 2 rigid shift first and falls back to tier 3 PatchMatch when the shift's
90
+ boundary continuity score is poor.
91
+
92
+ ## The two blend backends
93
+
94
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
95
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
96
+ *per pyramid level*.
97
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
98
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
99
+ Poisson is the safe default for single image inpainting because it reads
100
+ only the (valid) hole boundary; the compositor repairs the background under
101
+ the hole so no backend ever samples the object being removed.
102
+
103
+ ## Session persistence
104
+
105
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
106
+ removal is dispatched onto a background queue and run **under a lock** — never
107
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
108
+ empty session dirs left by aborted/crashed edits.
109
+
110
+ ## Status & scope
111
+
112
+ Implemented and tested: the full CPU pipeline across all stages. Auto select
113
+ is explicitly an *interface + CPU fallback*: a real segmentation model can be
114
+ dropped in behind `AutoSelectModel`, which ships a working color flood
115
+ fallback so tap to select runs end to end without one.
116
+
117
+ Engines run in pure Python/numpy for clarity; they are correct, not tuned for
118
+ speed. The hot inner loops (PatchMatch sweep, Poisson solve) are the obvious
119
+ targets for a native/GPU port.
120
+
121
+ ```
122
+ object_remove/
123
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
124
+ connected_components, auto_select
125
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler
126
+ fillengines/ patch_comparator (T1), self_similar_shift (T2), patchmatch/ (T3)
127
+ render/ multiband_blender, poisson_blender, compositor
128
+ session/ undo_session_manager, empty_session_sweeper
129
+ pipeline/ remove_object_job
130
+ ```
@@ -0,0 +1,102 @@
1
+ # object_remove
2
+
3
+ A from scratch, CPU implementation of an on device object removal pipeline (see
4
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
5
+ polygon/scanline mask layer, **three fill engines** of increasing
6
+ sophistication, **two interchangeable blend backends**, and a locked/queued
7
+ session persistence layer.
8
+
9
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
10
+ Hamming matching, PatchMatch, Laplacian/Poisson blending) — no OpenCV, no learned
11
+ weights.
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ pip install -r requirements.txt # numpy, scipy, Pillow
17
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
18
+ PYTHONPATH=. python -m pytest -q # 31 tests
19
+ ```
20
+
21
+ ```python
22
+ import numpy as np
23
+ from object_remove import RemoveObjectJob, EngineTier
24
+
25
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
26
+ progress=lambda stage, frac: ...)
27
+ removed = result.image # float32 RGB in [0, 1]
28
+ print(result.tier_used) # which engine AUTO picked
29
+ ```
30
+
31
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
32
+ nonzero marking the object to remove.
33
+
34
+ ## Module layout
35
+
36
+ | Stage | Concern | This repo |
37
+ |---|---|---|
38
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
39
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
40
+ | C1 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
41
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
42
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
43
+ | E — blend | multi band + Poisson | `object_remove/render/` |
44
+ | F — session | undo / persistence | `object_remove/session/` |
45
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
46
+
47
+ ## The three fill engines
48
+
49
+ - **Tier 1 — `SegmentAwarePatchFillEngine`** — greedy onion peel exemplar fill
50
+ scored by segment aware SSD with byte mask validity. Cheap, correct on small
51
+ holes; the low end / tiny hole fallback.
52
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
53
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
54
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
55
+ a transpose trick.
56
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
57
+ (random init → propagation → random search, then patch voting reconstruction,
58
+ coarse to fine). The quality path.
59
+
60
+ `EngineTier.AUTO` sends truly tiny holes to tier 1, otherwise tries the cheap
61
+ tier 2 rigid shift first and falls back to tier 3 PatchMatch when the shift's
62
+ boundary continuity score is poor.
63
+
64
+ ## The two blend backends
65
+
66
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
67
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
68
+ *per pyramid level*.
69
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
70
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
71
+ Poisson is the safe default for single image inpainting because it reads
72
+ only the (valid) hole boundary; the compositor repairs the background under
73
+ the hole so no backend ever samples the object being removed.
74
+
75
+ ## Session persistence
76
+
77
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
78
+ removal is dispatched onto a background queue and run **under a lock** — never
79
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
80
+ empty session dirs left by aborted/crashed edits.
81
+
82
+ ## Status & scope
83
+
84
+ Implemented and tested: the full CPU pipeline across all stages. Auto select
85
+ is explicitly an *interface + CPU fallback*: a real segmentation model can be
86
+ dropped in behind `AutoSelectModel`, which ships a working color flood
87
+ fallback so tap to select runs end to end without one.
88
+
89
+ Engines run in pure Python/numpy for clarity; they are correct, not tuned for
90
+ speed. The hot inner loops (PatchMatch sweep, Poisson solve) are the obvious
91
+ targets for a native/GPU port.
92
+
93
+ ```
94
+ object_remove/
95
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
96
+ connected_components, auto_select
97
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler
98
+ fillengines/ patch_comparator (T1), self_similar_shift (T2), patchmatch/ (T3)
99
+ render/ multiband_blender, poisson_blender, compositor
100
+ session/ undo_session_manager, empty_session_sweeper
101
+ pipeline/ remove_object_job
102
+ ```
@@ -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