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.
- object_remove-0.1.0/LICENSE +21 -0
- object_remove-0.1.0/PKG-INFO +130 -0
- object_remove-0.1.0/README.md +102 -0
- object_remove-0.1.0/object_remove/__init__.py +37 -0
- object_remove-0.1.0/object_remove/fillengines/__init__.py +20 -0
- object_remove-0.1.0/object_remove/fillengines/patch_comparator.py +182 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/__init__.py +41 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/group_solver.py +127 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/patch.py +48 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
- object_remove-0.1.0/object_remove/fillengines/patchmatch/solver.py +152 -0
- object_remove-0.1.0/object_remove/fillengines/self_similar_shift.py +368 -0
- object_remove-0.1.0/object_remove/imageutil.py +59 -0
- object_remove-0.1.0/object_remove/mask/__init__.py +22 -0
- object_remove-0.1.0/object_remove/mask/auto_select.py +84 -0
- object_remove-0.1.0/object_remove/mask/brush_rasterizer.py +97 -0
- object_remove-0.1.0/object_remove/mask/connected_components.py +110 -0
- object_remove-0.1.0/object_remove/mask/polygon_selection.py +219 -0
- object_remove-0.1.0/object_remove/mask/selection_enhancer.py +127 -0
- object_remove-0.1.0/object_remove/pipeline/__init__.py +7 -0
- object_remove-0.1.0/object_remove/pipeline/remove_object_job.py +154 -0
- object_remove-0.1.0/object_remove/py.typed +0 -0
- object_remove-0.1.0/object_remove/pyramid/__init__.py +15 -0
- object_remove-0.1.0/object_remove/pyramid/image_pyramid.py +101 -0
- object_remove-0.1.0/object_remove/pyramid/scale_scheduler.py +56 -0
- object_remove-0.1.0/object_remove/pyramid/tile_scheduler.py +80 -0
- object_remove-0.1.0/object_remove/render/__init__.py +16 -0
- object_remove-0.1.0/object_remove/render/compositor.py +62 -0
- object_remove-0.1.0/object_remove/render/multiband_blender.py +75 -0
- object_remove-0.1.0/object_remove/render/poisson_blender.py +132 -0
- object_remove-0.1.0/object_remove/session/__init__.py +11 -0
- object_remove-0.1.0/object_remove/session/empty_session_sweeper.py +49 -0
- object_remove-0.1.0/object_remove/session/undo_session_manager.py +178 -0
- object_remove-0.1.0/object_remove.egg-info/PKG-INFO +130 -0
- object_remove-0.1.0/object_remove.egg-info/SOURCES.txt +45 -0
- object_remove-0.1.0/object_remove.egg-info/dependency_links.txt +1 -0
- object_remove-0.1.0/object_remove.egg-info/requires.txt +6 -0
- object_remove-0.1.0/object_remove.egg-info/top_level.txt +1 -0
- object_remove-0.1.0/pyproject.toml +39 -0
- object_remove-0.1.0/setup.cfg +4 -0
- object_remove-0.1.0/tests/test_fillengines.py +70 -0
- object_remove-0.1.0/tests/test_mask.py +70 -0
- object_remove-0.1.0/tests/test_pipeline.py +61 -0
- object_remove-0.1.0/tests/test_pyramid.py +42 -0
- object_remove-0.1.0/tests/test_render.py +40 -0
- 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
|