object-remove 0.1.0__tar.gz → 0.2.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 (92) hide show
  1. object_remove-0.2.0/PKG-INFO +214 -0
  2. object_remove-0.2.0/README.md +183 -0
  3. object_remove-0.2.0/object_remove/cv/__init__.py +30 -0
  4. object_remove-0.2.0/object_remove/cv/box_filter.py +16 -0
  5. object_remove-0.2.0/object_remove/cv/canny.py +103 -0
  6. object_remove-0.2.0/object_remove/cv/distance_transform.py +39 -0
  7. object_remove-0.2.0/object_remove/cv/frequency_separation.py +34 -0
  8. object_remove-0.2.0/object_remove/cv/harris.py +63 -0
  9. object_remove-0.2.0/object_remove/cv/max_finder.py +61 -0
  10. object_remove-0.2.0/object_remove/cv/nearest_opaque.py +50 -0
  11. object_remove-0.2.0/object_remove/cv/orb.py +124 -0
  12. object_remove-0.2.0/object_remove/cv/symmetric_convolution.py +51 -0
  13. object_remove-0.2.0/object_remove/effects/__init__.py +17 -0
  14. object_remove-0.2.0/object_remove/effects/background_blur.py +57 -0
  15. object_remove-0.2.0/object_remove/effects/perspective_correction.py +168 -0
  16. object_remove-0.2.0/object_remove/fillengines/__init__.py +32 -0
  17. object_remove-0.2.0/object_remove/fillengines/clone_stamp.py +53 -0
  18. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patch_comparator.py +10 -1
  19. object_remove-0.2.0/object_remove/fillengines/patch_comparator_gpu.py +169 -0
  20. object_remove-0.2.0/object_remove/fillengines/patchmatch/__init__.py +62 -0
  21. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/group_solver.py +22 -6
  22. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch_scorer.py +2 -2
  23. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch_search_index.py +13 -12
  24. object_remove-0.2.0/object_remove/fillengines/patchmatch/torch_solver.py +241 -0
  25. object_remove-0.2.0/object_remove/fillengines/primary/__init__.py +143 -0
  26. object_remove-0.2.0/object_remove/fillengines/primary/patch_grid_solver.py +194 -0
  27. object_remove-0.2.0/object_remove/fillengines/primary/patch_grid_solver_gpu.py +182 -0
  28. object_remove-0.2.0/object_remove/fillengines/primary/quad_tree_index.py +136 -0
  29. object_remove-0.2.0/object_remove/fillengines/primary/renderer.py +35 -0
  30. object_remove-0.2.0/object_remove/fillengines/primary/scoring.py +47 -0
  31. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/self_similar_shift.py +38 -117
  32. object_remove-0.2.0/object_remove/fillengines/self_similar_shift_gpu.py +96 -0
  33. object_remove-0.2.0/object_remove/fillengines/wire_removal.py +172 -0
  34. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/auto_select.py +3 -3
  35. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/polygon_selection.py +6 -6
  36. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/selection_enhancer.py +20 -20
  37. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pipeline/remove_object_job.py +27 -10
  38. object_remove-0.2.0/object_remove/pyramid/scale_scheduler.py +98 -0
  39. object_remove-0.2.0/object_remove/pyramid/tile_scheduler.py +150 -0
  40. object_remove-0.2.0/object_remove/pyramid/torch_pyramid.py +89 -0
  41. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/__init__.py +5 -1
  42. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/compositor.py +8 -5
  43. object_remove-0.2.0/object_remove/render/fused_patch_blend.py +74 -0
  44. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/multiband_blender.py +15 -1
  45. object_remove-0.2.0/object_remove/render/multiband_blender_gpu.py +50 -0
  46. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/poisson_blender.py +37 -24
  47. object_remove-0.2.0/object_remove/render/poisson_blender_gpu.py +188 -0
  48. object_remove-0.2.0/object_remove/runtime/__init__.py +5 -0
  49. object_remove-0.2.0/object_remove/runtime/device.py +55 -0
  50. object_remove-0.2.0/object_remove.egg-info/PKG-INFO +214 -0
  51. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/SOURCES.txt +38 -1
  52. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/requires.txt +4 -0
  53. {object_remove-0.1.0 → object_remove-0.2.0}/pyproject.toml +5 -4
  54. object_remove-0.2.0/tests/test_clone_stamp.py +33 -0
  55. object_remove-0.2.0/tests/test_cv.py +111 -0
  56. object_remove-0.2.0/tests/test_effects.py +37 -0
  57. {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_fillengines.py +14 -7
  58. object_remove-0.2.0/tests/test_gpu.py +183 -0
  59. {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_mask.py +5 -5
  60. object_remove-0.2.0/tests/test_perspective_correction.py +63 -0
  61. {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_pipeline.py +6 -6
  62. object_remove-0.2.0/tests/test_primary_fill_engine.py +67 -0
  63. object_remove-0.2.0/tests/test_pyramid.py +96 -0
  64. {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_render.py +28 -4
  65. object_remove-0.2.0/tests/test_wire_removal.py +63 -0
  66. object_remove-0.1.0/PKG-INFO +0 -130
  67. object_remove-0.1.0/README.md +0 -102
  68. object_remove-0.1.0/object_remove/fillengines/__init__.py +0 -20
  69. object_remove-0.1.0/object_remove/fillengines/patchmatch/__init__.py +0 -41
  70. object_remove-0.1.0/object_remove/pyramid/scale_scheduler.py +0 -56
  71. object_remove-0.1.0/object_remove/pyramid/tile_scheduler.py +0 -80
  72. object_remove-0.1.0/object_remove.egg-info/PKG-INFO +0 -130
  73. object_remove-0.1.0/tests/test_pyramid.py +0 -42
  74. {object_remove-0.1.0 → object_remove-0.2.0}/LICENSE +0 -0
  75. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/__init__.py +0 -0
  76. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch.py +0 -0
  77. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/solver.py +0 -0
  78. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/imageutil.py +0 -0
  79. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/__init__.py +0 -0
  80. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/brush_rasterizer.py +0 -0
  81. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/connected_components.py +0 -0
  82. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pipeline/__init__.py +0 -0
  83. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/py.typed +0 -0
  84. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pyramid/__init__.py +0 -0
  85. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pyramid/image_pyramid.py +0 -0
  86. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/__init__.py +0 -0
  87. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/empty_session_sweeper.py +0 -0
  88. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/undo_session_manager.py +0 -0
  89. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/dependency_links.txt +0 -0
  90. {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/top_level.txt +0 -0
  91. {object_remove-0.1.0 → object_remove-0.2.0}/setup.cfg +0 -0
  92. {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_session.py +0 -0
@@ -0,0 +1,214 @@
1
+ Metadata-Version: 2.4
2
+ Name: object-remove
3
+ Version: 0.2.0
4
+ Summary: From-scratch reference implementation of a tiered on-device object-removal pipeline, CPU and GPU
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
+ Requires-Dist: torch>=2.2
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Provides-Extra: web
29
+ Requires-Dist: Flask>=2.2; extra == "web"
30
+ Dynamic: license-file
31
+
32
+ # object_remove
33
+
34
+ A from scratch implementation of an on device object removal pipeline (see
35
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
36
+ polygon/scanline mask layer, **four fill engines** of increasing
37
+ sophistication, **two interchangeable blend backends**, GPU acceleration
38
+ (CUDA + Apple MPS) that engages automatically when available, and a
39
+ locked/queued session persistence layer.
40
+
41
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
42
+ Hamming matching, a quad-tree patch-grid solver, PatchMatch, Laplacian/Poisson
43
+ blending) — no OpenCV, no learned weights.
44
+
45
+ ## Quick start
46
+
47
+ ```bash
48
+ pip install object-remove
49
+ ```
50
+
51
+ ```python
52
+ import numpy as np
53
+ from object_remove import RemoveObjectJob, EngineTier
54
+
55
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
56
+ progress=lambda stage, frac: ...)
57
+ removed = result.image # float32 RGB in [0, 1]
58
+ print(result.tier_used) # which engine AUTO picked
59
+ ```
60
+
61
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
62
+ nonzero marking the object to remove. Every engine and blender defaults to
63
+ `device="auto"`: use a GPU (CUDA on NVIDIA, MPS on Apple Silicon) when one is
64
+ available, otherwise the plain NumPy path — no separate install, no manual
65
+ opt in. Pass `"cuda"`/`"mps"`/`"cpu"` to force a specific torch backend, or
66
+ `device=None` to force plain NumPy regardless of hardware — see
67
+ [GPU acceleration](#gpu-acceleration) below.
68
+
69
+ **New to the package? Start with [`docs/USAGE.md`](docs/USAGE.md)** — it walks
70
+ through loading an image, building a mask three different ways (brush stroke,
71
+ polygon selection, tap-to-auto-select), refining it, running the removal, and
72
+ undo/session history, with runnable snippets for each.
73
+
74
+ To run from a source checkout instead of the installed package:
75
+
76
+ ```bash
77
+ pip install -r requirements.txt # numpy, scipy, Pillow, torch
78
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
79
+ PYTHONPATH=. python -m pytest -q # tests
80
+ ```
81
+
82
+ ## Module layout
83
+
84
+ | Stage | Concern | This repo |
85
+ |---|---|---|
86
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
87
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
88
+ | C0 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
89
+ | C1 — general solver | quad-tree patch-grid solve | `fillengines/primary/` |
90
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
91
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
92
+ | E — blend | multi band + Poisson | `object_remove/render/` |
93
+ | F — session | undo / persistence | `object_remove/session/` |
94
+ | — GPU runtime | device resolution | `object_remove/runtime/device.py` |
95
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
96
+
97
+ ## The four fill engines
98
+
99
+ - **Tier 1 comparator — `SegmentAwarePatchFillEngine`** — greedy onion peel
100
+ exemplar fill scored by segment aware SSD with byte mask validity. Cheap,
101
+ correct on small holes; the low end / tiny hole fallback.
102
+ - **Tier 1 primary — `PrimaryFillEngine`** — the general purpose solver: a
103
+ quad-tree patch search (`fillengines/primary/patch_search_tree.py`) over a
104
+ **sparse grid of patch centres** (not a dense per-pixel field), solved in
105
+ onion peel order with adaptive, blur-tested scale selection instead of a
106
+ fixed pyramid formula.
107
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
108
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
109
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
110
+ a transpose trick.
111
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
112
+ (random init → propagation → random search, then patch voting reconstruction,
113
+ coarse to fine). Available for explicit selection.
114
+
115
+ `EngineTier.AUTO` sends truly tiny holes to tier 1 comparator, otherwise
116
+ tries the cheap tier 2 rigid shift first and falls back to tier 1 primary
117
+ when the shift's boundary continuity score is poor.
118
+
119
+ ## GPU acceleration
120
+
121
+ `torch` ships as a regular dependency — one `pip install object-remove` gets
122
+ both CPU and GPU support, nothing extra to install. Every fill engine and
123
+ both blenders accept a `device` kwarg, defaulting to `"auto"`: use a GPU
124
+ (CUDA on NVIDIA, MPS on Apple Silicon) when one is available, otherwise the
125
+ plain NumPy path. Explicit `"cuda"`/`"mps"`/`"cpu"` force that exact torch
126
+ backend (and raise clearly if it isn't available); `device=None` forces
127
+ plain NumPy regardless of hardware.
128
+
129
+ ```python
130
+ from object_remove.render.multiband_blender import MultiBandBlender
131
+ from object_remove.fillengines import PrimaryFillEngine
132
+
133
+ blender = MultiBandBlender(bands=3) # device="auto" by default
134
+ engine = PrimaryFillEngine(device=None) # force plain NumPy
135
+ ```
136
+
137
+ Notes on the GPU path:
138
+
139
+ - The multi-band blender is a straight port (pure array math). The Poisson
140
+ blender solves the identical linear system via matrix-free conjugate
141
+ gradient instead of an exact sparse solve. The PatchMatch solver uses
142
+ red-black checkerboard propagation instead of raster order, so GPU output
143
+ legitimately differs pixel for pixel from the CPU path (same quality bar,
144
+ different but equally valid traversal order).
145
+ - The torch path runs in **float32** throughout (Apple MPS has weak/no
146
+ float64 support) — a deliberate precision tradeoff vs. the plain NumPy
147
+ path's float64.
148
+
149
+ ## The two blend backends
150
+
151
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
152
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
153
+ *per pyramid level*.
154
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
155
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
156
+ Poisson is the safe default for single image inpainting because it reads
157
+ only the (valid) hole boundary; the compositor repairs the background under
158
+ the hole so no backend ever samples the object being removed.
159
+
160
+ ## Session persistence
161
+
162
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
163
+ removal is dispatched onto a background queue and run **under a lock** — never
164
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
165
+ empty session dirs left by aborted/crashed edits.
166
+
167
+ ## Beyond object removal
168
+
169
+ A handful of adjacent features share the same primitives as object removal
170
+ rather than each reimplementing their own:
171
+
172
+ - **`object_remove/cv/`** — reusable classic CV building blocks: ORB
173
+ (FAST + BRIEF + Hamming, also used by tier 2), Harris/Shi-Tomasi corners,
174
+ Canny edges, a distance transform, a separable max filter / non-max
175
+ suppression, a generic sigma-configurable Gaussian/box blur, nearest-opaque
176
+ fill, frequency separation.
177
+ - **`object_remove/effects/`** — `background_blur` (selection-distance
178
+ driven falloff, since no depth model ships) and
179
+ `FourPointPerspectiveCorrector` (4-point homography + inverse warp).
180
+ - **`fillengines/clone_stamp.py`** — manual clone stamp: a user-specified
181
+ source offset instead of a searched one, composing with `PatchCompositor`
182
+ like any other engine.
183
+ - **`fillengines/wire_removal.py`** — wire/cable removal: a *derived*
184
+ (Hessian ridge traced), not painted, mask acquisition model, feeding the
185
+ same fill engines used for object removal.
186
+
187
+ ## Status & scope
188
+
189
+ Implemented and tested: the full pipeline across all stages, CPU (NumPy) and
190
+ optional GPU (PyTorch: CUDA + Apple MPS) backends for every fill engine and
191
+ blender, plus the adjacent features above. Auto select is explicitly an
192
+ *interface + CPU fallback*: a real segmentation model can be dropped in
193
+ behind `AutoSelectModel`, which ships a working color flood fallback so tap
194
+ to select runs end to end without one.
195
+
196
+ ```
197
+ object_remove/
198
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
199
+ connected_components, auto_select
200
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler, torch_pyramid
201
+ fillengines/ patch_comparator (T1 comparator), primary/ (T1 general solver),
202
+ self_similar_shift (T2), patchmatch/ (T3),
203
+ clone_stamp, wire_removal
204
+ each fill engine has a *_gpu.py / torch_*.py sibling module
205
+ render/ multiband_blender(+_gpu), poisson_blender(+_gpu), compositor,
206
+ fused_patch_blend
207
+ cv/ orb, harris, canny, distance_transform, max_finder,
208
+ symmetric_convolution, box_filter, nearest_opaque,
209
+ frequency_separation
210
+ effects/ background_blur, perspective_correction
211
+ runtime/ device (GPU device resolution)
212
+ session/ undo_session_manager, empty_session_sweeper
213
+ pipeline/ remove_object_job
214
+ ```
@@ -0,0 +1,183 @@
1
+ # object_remove
2
+
3
+ A from scratch 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, **four fill engines** of increasing
6
+ sophistication, **two interchangeable blend backends**, GPU acceleration
7
+ (CUDA + Apple MPS) that engages automatically when available, and a
8
+ locked/queued session persistence layer.
9
+
10
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
11
+ Hamming matching, a quad-tree patch-grid solver, PatchMatch, Laplacian/Poisson
12
+ blending) — no OpenCV, no learned weights.
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ pip install object-remove
18
+ ```
19
+
20
+ ```python
21
+ import numpy as np
22
+ from object_remove import RemoveObjectJob, EngineTier
23
+
24
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
25
+ progress=lambda stage, frac: ...)
26
+ removed = result.image # float32 RGB in [0, 1]
27
+ print(result.tier_used) # which engine AUTO picked
28
+ ```
29
+
30
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
31
+ nonzero marking the object to remove. Every engine and blender defaults to
32
+ `device="auto"`: use a GPU (CUDA on NVIDIA, MPS on Apple Silicon) when one is
33
+ available, otherwise the plain NumPy path — no separate install, no manual
34
+ opt in. Pass `"cuda"`/`"mps"`/`"cpu"` to force a specific torch backend, or
35
+ `device=None` to force plain NumPy regardless of hardware — see
36
+ [GPU acceleration](#gpu-acceleration) below.
37
+
38
+ **New to the package? Start with [`docs/USAGE.md`](docs/USAGE.md)** — it walks
39
+ through loading an image, building a mask three different ways (brush stroke,
40
+ polygon selection, tap-to-auto-select), refining it, running the removal, and
41
+ undo/session history, with runnable snippets for each.
42
+
43
+ To run from a source checkout instead of the installed package:
44
+
45
+ ```bash
46
+ pip install -r requirements.txt # numpy, scipy, Pillow, torch
47
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
48
+ PYTHONPATH=. python -m pytest -q # tests
49
+ ```
50
+
51
+ ## Module layout
52
+
53
+ | Stage | Concern | This repo |
54
+ |---|---|---|
55
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
56
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
57
+ | C0 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
58
+ | C1 — general solver | quad-tree patch-grid solve | `fillengines/primary/` |
59
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
60
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
61
+ | E — blend | multi band + Poisson | `object_remove/render/` |
62
+ | F — session | undo / persistence | `object_remove/session/` |
63
+ | — GPU runtime | device resolution | `object_remove/runtime/device.py` |
64
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
65
+
66
+ ## The four fill engines
67
+
68
+ - **Tier 1 comparator — `SegmentAwarePatchFillEngine`** — greedy onion peel
69
+ exemplar fill scored by segment aware SSD with byte mask validity. Cheap,
70
+ correct on small holes; the low end / tiny hole fallback.
71
+ - **Tier 1 primary — `PrimaryFillEngine`** — the general purpose solver: a
72
+ quad-tree patch search (`fillengines/primary/patch_search_tree.py`) over a
73
+ **sparse grid of patch centres** (not a dense per-pixel field), solved in
74
+ onion peel order with adaptive, blur-tested scale selection instead of a
75
+ fixed pyramid formula.
76
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
77
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
78
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
79
+ a transpose trick.
80
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
81
+ (random init → propagation → random search, then patch voting reconstruction,
82
+ coarse to fine). Available for explicit selection.
83
+
84
+ `EngineTier.AUTO` sends truly tiny holes to tier 1 comparator, otherwise
85
+ tries the cheap tier 2 rigid shift first and falls back to tier 1 primary
86
+ when the shift's boundary continuity score is poor.
87
+
88
+ ## GPU acceleration
89
+
90
+ `torch` ships as a regular dependency — one `pip install object-remove` gets
91
+ both CPU and GPU support, nothing extra to install. Every fill engine and
92
+ both blenders accept a `device` kwarg, defaulting to `"auto"`: use a GPU
93
+ (CUDA on NVIDIA, MPS on Apple Silicon) when one is available, otherwise the
94
+ plain NumPy path. Explicit `"cuda"`/`"mps"`/`"cpu"` force that exact torch
95
+ backend (and raise clearly if it isn't available); `device=None` forces
96
+ plain NumPy regardless of hardware.
97
+
98
+ ```python
99
+ from object_remove.render.multiband_blender import MultiBandBlender
100
+ from object_remove.fillengines import PrimaryFillEngine
101
+
102
+ blender = MultiBandBlender(bands=3) # device="auto" by default
103
+ engine = PrimaryFillEngine(device=None) # force plain NumPy
104
+ ```
105
+
106
+ Notes on the GPU path:
107
+
108
+ - The multi-band blender is a straight port (pure array math). The Poisson
109
+ blender solves the identical linear system via matrix-free conjugate
110
+ gradient instead of an exact sparse solve. The PatchMatch solver uses
111
+ red-black checkerboard propagation instead of raster order, so GPU output
112
+ legitimately differs pixel for pixel from the CPU path (same quality bar,
113
+ different but equally valid traversal order).
114
+ - The torch path runs in **float32** throughout (Apple MPS has weak/no
115
+ float64 support) — a deliberate precision tradeoff vs. the plain NumPy
116
+ path's float64.
117
+
118
+ ## The two blend backends
119
+
120
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
121
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
122
+ *per pyramid level*.
123
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
124
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
125
+ Poisson is the safe default for single image inpainting because it reads
126
+ only the (valid) hole boundary; the compositor repairs the background under
127
+ the hole so no backend ever samples the object being removed.
128
+
129
+ ## Session persistence
130
+
131
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
132
+ removal is dispatched onto a background queue and run **under a lock** — never
133
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
134
+ empty session dirs left by aborted/crashed edits.
135
+
136
+ ## Beyond object removal
137
+
138
+ A handful of adjacent features share the same primitives as object removal
139
+ rather than each reimplementing their own:
140
+
141
+ - **`object_remove/cv/`** — reusable classic CV building blocks: ORB
142
+ (FAST + BRIEF + Hamming, also used by tier 2), Harris/Shi-Tomasi corners,
143
+ Canny edges, a distance transform, a separable max filter / non-max
144
+ suppression, a generic sigma-configurable Gaussian/box blur, nearest-opaque
145
+ fill, frequency separation.
146
+ - **`object_remove/effects/`** — `background_blur` (selection-distance
147
+ driven falloff, since no depth model ships) and
148
+ `FourPointPerspectiveCorrector` (4-point homography + inverse warp).
149
+ - **`fillengines/clone_stamp.py`** — manual clone stamp: a user-specified
150
+ source offset instead of a searched one, composing with `PatchCompositor`
151
+ like any other engine.
152
+ - **`fillengines/wire_removal.py`** — wire/cable removal: a *derived*
153
+ (Hessian ridge traced), not painted, mask acquisition model, feeding the
154
+ same fill engines used for object removal.
155
+
156
+ ## Status & scope
157
+
158
+ Implemented and tested: the full pipeline across all stages, CPU (NumPy) and
159
+ optional GPU (PyTorch: CUDA + Apple MPS) backends for every fill engine and
160
+ blender, plus the adjacent features above. Auto select is explicitly an
161
+ *interface + CPU fallback*: a real segmentation model can be dropped in
162
+ behind `AutoSelectModel`, which ships a working color flood fallback so tap
163
+ to select runs end to end without one.
164
+
165
+ ```
166
+ object_remove/
167
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
168
+ connected_components, auto_select
169
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler, torch_pyramid
170
+ fillengines/ patch_comparator (T1 comparator), primary/ (T1 general solver),
171
+ self_similar_shift (T2), patchmatch/ (T3),
172
+ clone_stamp, wire_removal
173
+ each fill engine has a *_gpu.py / torch_*.py sibling module
174
+ render/ multiband_blender(+_gpu), poisson_blender(+_gpu), compositor,
175
+ fused_patch_blend
176
+ cv/ orb, harris, canny, distance_transform, max_finder,
177
+ symmetric_convolution, box_filter, nearest_opaque,
178
+ frequency_separation
179
+ effects/ background_blur, perspective_correction
180
+ runtime/ device (GPU device resolution)
181
+ session/ undo_session_manager, empty_session_sweeper
182
+ pipeline/ remove_object_job
183
+ ```
@@ -0,0 +1,30 @@
1
+ """Classic computer vision building blocks, exposed as standalone, reusable
2
+ primitives rather than bundled inside whichever feature happens to use them
3
+ first. ``fillengines/self_similar_shift.py`` (tier 2) imports :mod:`orb`
4
+ rather than keeping a duplicate copy.
5
+ """
6
+
7
+ from .orb import Keypoint, fast_corners, brief_descriptors, hamming
8
+ from .harris import (
9
+ harris_response, shi_tomasi_response, harris_corners, good_features_to_track, structure_tensor,
10
+ )
11
+ from .canny import canny, sobel_gradients
12
+ from .distance_transform import distance_transform
13
+ from .box_filter import box_filter
14
+ from .symmetric_convolution import separable_convolve, gaussian_kernel_1d, gaussian_blur_sigma
15
+ from .nearest_opaque import fill_nearest_opaque
16
+ from .max_finder import local_max_filter, non_max_suppression
17
+ from .frequency_separation import FrequencyBands, separate, recombine, smooth_low_band
18
+
19
+ __all__ = [
20
+ "Keypoint", "fast_corners", "brief_descriptors", "hamming",
21
+ "harris_response", "shi_tomasi_response", "harris_corners", "good_features_to_track",
22
+ "structure_tensor",
23
+ "canny", "sobel_gradients",
24
+ "distance_transform",
25
+ "box_filter",
26
+ "separable_convolve", "gaussian_kernel_1d", "gaussian_blur_sigma",
27
+ "fill_nearest_opaque",
28
+ "local_max_filter", "non_max_suppression",
29
+ "FrequencyBands", "separate", "recombine", "smooth_low_band",
30
+ ]
@@ -0,0 +1,16 @@
1
+ """Uniform box blur: a flat kernel through the same separable runner as
2
+ everything else in :mod:`symmetric_convolution`.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import numpy as np
8
+
9
+ from .symmetric_convolution import separable_convolve
10
+
11
+
12
+ def box_filter(img: np.ndarray, radius: int) -> np.ndarray:
13
+ if radius <= 0:
14
+ return img.astype(np.float64)
15
+ k = np.full(2 * radius + 1, 1.0 / (2 * radius + 1))
16
+ return separable_convolve(img, k)
@@ -0,0 +1,103 @@
1
+ """Canny edge detection.
2
+
3
+ Gaussian smooth, Sobel gradients, non maximum suppression *along the
4
+ gradient direction* (thinning, distinct from :mod:`max_finder`'s square
5
+ window suppression), then hysteresis thresholding. Weak edges are kept only
6
+ if 8-connected to a strong edge, found via the same repeated-dilation BFS
7
+ technique used by :func:`distance_transform.distance_transform`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from .symmetric_convolution import gaussian_blur_sigma
15
+
16
+
17
+ def _convolve3x3(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
18
+ H, W = img.shape
19
+ padded = np.pad(img, 1, mode="edge")
20
+ out = np.zeros((H, W), dtype=np.float64)
21
+ for i in range(3):
22
+ for j in range(3):
23
+ out += kernel[i, j] * padded[i:i + H, j:j + W]
24
+ return out
25
+
26
+
27
+ _SOBEL_X = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float64)
28
+ _SOBEL_Y = _SOBEL_X.T
29
+
30
+
31
+ def sobel_gradients(gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
32
+ return _convolve3x3(gray, _SOBEL_X), _convolve3x3(gray, _SOBEL_Y)
33
+
34
+
35
+ def _shift2d(arr: np.ndarray, dy: int, dx: int, fill: float = 0.0) -> np.ndarray:
36
+ H, W = arr.shape
37
+ out = np.full_like(arr, fill)
38
+ y_lo, y_hi = max(0, -dy), H - max(0, dy)
39
+ x_lo, x_hi = max(0, -dx), W - max(0, dx)
40
+ if y_lo < y_hi and x_lo < x_hi:
41
+ out[y_lo:y_hi, x_lo:x_hi] = arr[y_lo + dy:y_hi + dy, x_lo + dx:x_hi + dx]
42
+ return out
43
+
44
+
45
+ _DIRECTION_NEIGHBOURS = {
46
+ 0: ((0, -1), (0, 1)), # gradient ~horizontal -> compare left/right
47
+ 1: ((-1, 1), (1, -1)), # gradient ~45 deg
48
+ 2: ((-1, 0), (1, 0)), # gradient ~vertical -> compare up/down
49
+ 3: ((-1, -1), (1, 1)), # gradient ~135 deg
50
+ }
51
+
52
+
53
+ def _thin_by_direction(mag: np.ndarray, angle: np.ndarray) -> np.ndarray:
54
+ angle_deg = np.degrees(angle) % 180
55
+ q = np.zeros(angle_deg.shape, dtype=np.int32)
56
+ q[(angle_deg >= 22.5) & (angle_deg < 67.5)] = 1
57
+ q[(angle_deg >= 67.5) & (angle_deg < 112.5)] = 2
58
+ q[(angle_deg >= 112.5) & (angle_deg < 157.5)] = 3
59
+
60
+ keep = np.ones(mag.shape, dtype=bool)
61
+ for qi, (o1, o2) in _DIRECTION_NEIGHBOURS.items():
62
+ sel = q == qi
63
+ n1 = _shift2d(mag, *o1)
64
+ n2 = _shift2d(mag, *o2)
65
+ keep &= ~(sel & ((mag < n1) | (mag < n2)))
66
+ return np.where(keep, mag, 0.0)
67
+
68
+
69
+ def _hysteresis(thin: np.ndarray, low: float, high: float) -> np.ndarray:
70
+ strong = thin >= high
71
+ weak = (thin >= low) & ~strong
72
+ connected = strong.copy()
73
+ frontier = strong.copy()
74
+ while frontier.any():
75
+ dil = frontier.copy()
76
+ dil[1:, :] |= frontier[:-1, :]
77
+ dil[:-1, :] |= frontier[1:, :]
78
+ dil[:, 1:] |= frontier[:, :-1]
79
+ dil[:, :-1] |= frontier[:, 1:]
80
+ dil[1:, 1:] |= frontier[:-1, :-1]
81
+ dil[1:, :-1] |= frontier[:-1, 1:]
82
+ dil[:-1, 1:] |= frontier[1:, :-1]
83
+ dil[:-1, :-1] |= frontier[1:, 1:]
84
+ newly = dil & weak & ~connected
85
+ if not newly.any():
86
+ break
87
+ connected |= newly
88
+ frontier = newly
89
+ return connected
90
+
91
+
92
+ def canny(gray: np.ndarray, sigma: float = 1.0, low_threshold_ratio: float = 0.1,
93
+ high_threshold_ratio: float = 0.2) -> np.ndarray:
94
+ """Boolean edge mask."""
95
+ smoothed = gaussian_blur_sigma(gray, sigma)
96
+ gx, gy = sobel_gradients(smoothed)
97
+ mag = np.hypot(gx, gy)
98
+ angle = np.arctan2(gy, gx)
99
+ thin = _thin_by_direction(mag, angle)
100
+ peak = float(thin.max())
101
+ if peak <= 0:
102
+ return np.zeros(gray.shape, dtype=bool)
103
+ return _hysteresis(thin, peak * low_threshold_ratio, peak * high_threshold_ratio)
@@ -0,0 +1,39 @@
1
+ """Distance to the nearest mask boundary, feeding feathering/falloff weights
2
+ (background blur, soft masks).
3
+
4
+ Approximate (8-connected, Chebyshev-ish) distance via repeated boolean
5
+ dilation, fully vectorised per round rather than a per pixel Python scan.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+
13
+ def distance_transform(mask: np.ndarray, connectivity: int = 8) -> np.ndarray:
14
+ """Distance from each ``True`` pixel to the nearest ``False`` pixel."""
15
+ H, W = mask.shape
16
+ dist = np.zeros((H, W), dtype=np.float64)
17
+ remaining = mask.copy()
18
+ frontier = ~mask
19
+ d = 0
20
+ while remaining.any():
21
+ d += 1
22
+ dil = frontier.copy()
23
+ dil[1:, :] |= frontier[:-1, :]
24
+ dil[:-1, :] |= frontier[1:, :]
25
+ dil[:, 1:] |= frontier[:, :-1]
26
+ dil[:, :-1] |= frontier[:, 1:]
27
+ if connectivity == 8:
28
+ dil[1:, 1:] |= frontier[:-1, :-1]
29
+ dil[1:, :-1] |= frontier[:-1, 1:]
30
+ dil[:-1, 1:] |= frontier[1:, :-1]
31
+ dil[:-1, :-1] |= frontier[1:, 1:]
32
+ newly = dil & remaining
33
+ if not newly.any():
34
+ dist[remaining] = d
35
+ break
36
+ dist[newly] = d
37
+ remaining &= ~newly
38
+ frontier = newly
39
+ return dist
@@ -0,0 +1,34 @@
1
+ """Low/high frequency band split: split into a low band (smooth, blurred)
2
+ and a high band (the residual detail), edit the low band, keep the high
3
+ band untouched, recombine. Built on the sigma configurable Gaussian blur in
4
+ :mod:`symmetric_convolution`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import numpy as np
12
+
13
+ from .symmetric_convolution import gaussian_blur_sigma
14
+
15
+
16
+ @dataclass
17
+ class FrequencyBands:
18
+ low: np.ndarray
19
+ high: np.ndarray
20
+
21
+
22
+ def separate(image: np.ndarray, sigma: float = 3.0) -> FrequencyBands:
23
+ low = gaussian_blur_sigma(image, sigma)
24
+ high = image.astype(np.float64) - low
25
+ return FrequencyBands(low, high)
26
+
27
+
28
+ def recombine(bands: FrequencyBands) -> np.ndarray:
29
+ return bands.low + bands.high
30
+
31
+
32
+ def smooth_low_band(bands: FrequencyBands, extra_sigma: float = 2.0) -> FrequencyBands:
33
+ """Soften the low band further while leaving the high band (real detail) untouched."""
34
+ return FrequencyBands(gaussian_blur_sigma(bands.low, extra_sigma), bands.high)