object-remove 0.1.0__tar.gz → 0.2.1__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.1/PKG-INFO +216 -0
  2. object_remove-0.2.1/README.md +185 -0
  3. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/__init__.py +1 -1
  4. object_remove-0.2.1/object_remove/cv/__init__.py +30 -0
  5. object_remove-0.2.1/object_remove/cv/box_filter.py +16 -0
  6. object_remove-0.2.1/object_remove/cv/canny.py +103 -0
  7. object_remove-0.2.1/object_remove/cv/distance_transform.py +39 -0
  8. object_remove-0.2.1/object_remove/cv/frequency_separation.py +34 -0
  9. object_remove-0.2.1/object_remove/cv/harris.py +63 -0
  10. object_remove-0.2.1/object_remove/cv/max_finder.py +61 -0
  11. object_remove-0.2.1/object_remove/cv/nearest_opaque.py +50 -0
  12. object_remove-0.2.1/object_remove/cv/orb.py +120 -0
  13. object_remove-0.2.1/object_remove/cv/symmetric_convolution.py +51 -0
  14. object_remove-0.2.1/object_remove/effects/__init__.py +17 -0
  15. object_remove-0.2.1/object_remove/effects/background_blur.py +57 -0
  16. object_remove-0.2.1/object_remove/effects/perspective_correction.py +168 -0
  17. object_remove-0.2.1/object_remove/fillengines/__init__.py +32 -0
  18. object_remove-0.2.1/object_remove/fillengines/clone_stamp.py +53 -0
  19. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patch_comparator.py +10 -1
  20. object_remove-0.2.1/object_remove/fillengines/patch_comparator_gpu.py +169 -0
  21. object_remove-0.2.1/object_remove/fillengines/patchmatch/__init__.py +62 -0
  22. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patchmatch/group_solver.py +22 -6
  23. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patchmatch/patch_scorer.py +2 -2
  24. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patchmatch/patch_search_index.py +13 -12
  25. object_remove-0.2.1/object_remove/fillengines/patchmatch/torch_solver.py +241 -0
  26. object_remove-0.2.1/object_remove/fillengines/primary/__init__.py +143 -0
  27. object_remove-0.2.1/object_remove/fillengines/primary/patch_grid_solver.py +194 -0
  28. object_remove-0.2.1/object_remove/fillengines/primary/patch_grid_solver_gpu.py +182 -0
  29. object_remove-0.2.1/object_remove/fillengines/primary/quad_tree_index.py +145 -0
  30. object_remove-0.2.1/object_remove/fillengines/primary/renderer.py +35 -0
  31. object_remove-0.2.1/object_remove/fillengines/primary/scoring.py +47 -0
  32. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/self_similar_shift.py +66 -127
  33. object_remove-0.2.1/object_remove/fillengines/self_similar_shift_gpu.py +96 -0
  34. object_remove-0.2.1/object_remove/fillengines/wire_removal.py +172 -0
  35. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/auto_select.py +3 -3
  36. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/polygon_selection.py +6 -6
  37. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/selection_enhancer.py +20 -20
  38. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/pipeline/remove_object_job.py +27 -10
  39. object_remove-0.2.1/object_remove/pyramid/scale_scheduler.py +98 -0
  40. object_remove-0.2.1/object_remove/pyramid/tile_scheduler.py +150 -0
  41. object_remove-0.2.1/object_remove/pyramid/torch_pyramid.py +89 -0
  42. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/render/__init__.py +5 -1
  43. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/render/compositor.py +8 -5
  44. object_remove-0.2.1/object_remove/render/fused_patch_blend.py +74 -0
  45. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/render/multiband_blender.py +15 -1
  46. object_remove-0.2.1/object_remove/render/multiband_blender_gpu.py +50 -0
  47. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/render/poisson_blender.py +37 -24
  48. object_remove-0.2.1/object_remove/render/poisson_blender_gpu.py +188 -0
  49. object_remove-0.2.1/object_remove/runtime/__init__.py +5 -0
  50. object_remove-0.2.1/object_remove/runtime/device.py +55 -0
  51. object_remove-0.2.1/object_remove.egg-info/PKG-INFO +216 -0
  52. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove.egg-info/SOURCES.txt +38 -1
  53. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove.egg-info/requires.txt +4 -0
  54. {object_remove-0.1.0 → object_remove-0.2.1}/pyproject.toml +5 -4
  55. object_remove-0.2.1/tests/test_clone_stamp.py +33 -0
  56. object_remove-0.2.1/tests/test_cv.py +111 -0
  57. object_remove-0.2.1/tests/test_effects.py +37 -0
  58. {object_remove-0.1.0 → object_remove-0.2.1}/tests/test_fillengines.py +14 -7
  59. object_remove-0.2.1/tests/test_gpu.py +183 -0
  60. {object_remove-0.1.0 → object_remove-0.2.1}/tests/test_mask.py +5 -5
  61. object_remove-0.2.1/tests/test_perspective_correction.py +63 -0
  62. {object_remove-0.1.0 → object_remove-0.2.1}/tests/test_pipeline.py +6 -6
  63. object_remove-0.2.1/tests/test_primary_fill_engine.py +67 -0
  64. object_remove-0.2.1/tests/test_pyramid.py +96 -0
  65. {object_remove-0.1.0 → object_remove-0.2.1}/tests/test_render.py +28 -4
  66. object_remove-0.2.1/tests/test_wire_removal.py +63 -0
  67. object_remove-0.1.0/PKG-INFO +0 -130
  68. object_remove-0.1.0/README.md +0 -102
  69. object_remove-0.1.0/object_remove/fillengines/__init__.py +0 -20
  70. object_remove-0.1.0/object_remove/fillengines/patchmatch/__init__.py +0 -41
  71. object_remove-0.1.0/object_remove/pyramid/scale_scheduler.py +0 -56
  72. object_remove-0.1.0/object_remove/pyramid/tile_scheduler.py +0 -80
  73. object_remove-0.1.0/object_remove.egg-info/PKG-INFO +0 -130
  74. object_remove-0.1.0/tests/test_pyramid.py +0 -42
  75. {object_remove-0.1.0 → object_remove-0.2.1}/LICENSE +0 -0
  76. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patchmatch/patch.py +0 -0
  77. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/fillengines/patchmatch/solver.py +0 -0
  78. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/imageutil.py +0 -0
  79. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/__init__.py +0 -0
  80. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/brush_rasterizer.py +0 -0
  81. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/mask/connected_components.py +0 -0
  82. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/pipeline/__init__.py +0 -0
  83. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/py.typed +0 -0
  84. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/pyramid/__init__.py +0 -0
  85. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/pyramid/image_pyramid.py +0 -0
  86. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/session/__init__.py +0 -0
  87. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/session/empty_session_sweeper.py +0 -0
  88. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove/session/undo_session_manager.py +0 -0
  89. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove.egg-info/dependency_links.txt +0 -0
  90. {object_remove-0.1.0 → object_remove-0.2.1}/object_remove.egg-info/top_level.txt +0 -0
  91. {object_remove-0.1.0 → object_remove-0.2.1}/setup.cfg +0 -0
  92. {object_remove-0.1.0 → object_remove-0.2.1}/tests/test_session.py +0 -0
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: object-remove
3
+ Version: 0.2.1
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
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/0.2.1/)
35
+
36
+ A from scratch implementation of an on device object removal pipeline (see
37
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
38
+ polygon/scanline mask layer, **four fill engines** of increasing
39
+ sophistication, **two interchangeable blend backends**, GPU acceleration
40
+ (CUDA + Apple MPS) that engages automatically when available, and a
41
+ locked/queued session persistence layer.
42
+
43
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
44
+ Hamming matching, a quad-tree patch-grid solver, PatchMatch, Laplacian/Poisson
45
+ blending) — no OpenCV, no learned weights.
46
+
47
+ ## Quick start
48
+
49
+ ```bash
50
+ pip install object-remove
51
+ ```
52
+
53
+ ```python
54
+ import numpy as np
55
+ from object_remove import RemoveObjectJob, EngineTier
56
+
57
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
58
+ progress=lambda stage, frac: ...)
59
+ removed = result.image # float32 RGB in [0, 1]
60
+ print(result.tier_used) # which engine AUTO picked
61
+ ```
62
+
63
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
64
+ nonzero marking the object to remove. Every engine and blender defaults to
65
+ `device="auto"`: use a GPU (CUDA on NVIDIA, MPS on Apple Silicon) when one is
66
+ available, otherwise the plain NumPy path — no separate install, no manual
67
+ opt in. Pass `"cuda"`/`"mps"`/`"cpu"` to force a specific torch backend, or
68
+ `device=None` to force plain NumPy regardless of hardware — see
69
+ [GPU acceleration](#gpu-acceleration) below.
70
+
71
+ **New to the package? Start with [`docs/USAGE.md`](docs/USAGE.md)** — it walks
72
+ through loading an image, building a mask three different ways (brush stroke,
73
+ polygon selection, tap-to-auto-select), refining it, running the removal, and
74
+ undo/session history, with runnable snippets for each.
75
+
76
+ To run from a source checkout instead of the installed package:
77
+
78
+ ```bash
79
+ pip install -r requirements.txt # numpy, scipy, Pillow, torch
80
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
81
+ PYTHONPATH=. python -m pytest -q # tests
82
+ ```
83
+
84
+ ## Module layout
85
+
86
+ | Stage | Concern | This repo |
87
+ |---|---|---|
88
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
89
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
90
+ | C0 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
91
+ | C1 — general solver | quad-tree patch-grid solve | `fillengines/primary/` |
92
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
93
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
94
+ | E — blend | multi band + Poisson | `object_remove/render/` |
95
+ | F — session | undo / persistence | `object_remove/session/` |
96
+ | — GPU runtime | device resolution | `object_remove/runtime/device.py` |
97
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
98
+
99
+ ## The four fill engines
100
+
101
+ - **Tier 1 comparator — `SegmentAwarePatchFillEngine`** — greedy onion peel
102
+ exemplar fill scored by segment aware SSD with byte mask validity. Cheap,
103
+ correct on small holes; the low end / tiny hole fallback.
104
+ - **Tier 1 primary — `PrimaryFillEngine`** — the general purpose solver: a
105
+ quad-tree patch search (`fillengines/primary/patch_search_tree.py`) over a
106
+ **sparse grid of patch centres** (not a dense per-pixel field), solved in
107
+ onion peel order with adaptive, blur-tested scale selection instead of a
108
+ fixed pyramid formula.
109
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
110
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
111
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
112
+ a transpose trick.
113
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
114
+ (random init → propagation → random search, then patch voting reconstruction,
115
+ coarse to fine). Available for explicit selection.
116
+
117
+ `EngineTier.AUTO` sends truly tiny holes to tier 1 comparator, otherwise
118
+ tries the cheap tier 2 rigid shift first and falls back to tier 1 primary
119
+ when the shift's boundary continuity score is poor.
120
+
121
+ ## GPU acceleration
122
+
123
+ `torch` ships as a regular dependency — one `pip install object-remove` gets
124
+ both CPU and GPU support, nothing extra to install. Every fill engine and
125
+ both blenders accept a `device` kwarg, defaulting to `"auto"`: use a GPU
126
+ (CUDA on NVIDIA, MPS on Apple Silicon) when one is available, otherwise the
127
+ plain NumPy path. Explicit `"cuda"`/`"mps"`/`"cpu"` force that exact torch
128
+ backend (and raise clearly if it isn't available); `device=None` forces
129
+ plain NumPy regardless of hardware.
130
+
131
+ ```python
132
+ from object_remove.render.multiband_blender import MultiBandBlender
133
+ from object_remove.fillengines import PrimaryFillEngine
134
+
135
+ blender = MultiBandBlender(bands=3) # device="auto" by default
136
+ engine = PrimaryFillEngine(device=None) # force plain NumPy
137
+ ```
138
+
139
+ Notes on the GPU path:
140
+
141
+ - The multi-band blender is a straight port (pure array math). The Poisson
142
+ blender solves the identical linear system via matrix-free conjugate
143
+ gradient instead of an exact sparse solve. The PatchMatch solver uses
144
+ red-black checkerboard propagation instead of raster order, so GPU output
145
+ legitimately differs pixel for pixel from the CPU path (same quality bar,
146
+ different but equally valid traversal order).
147
+ - The torch path runs in **float32** throughout (Apple MPS has weak/no
148
+ float64 support) — a deliberate precision tradeoff vs. the plain NumPy
149
+ path's float64.
150
+
151
+ ## The two blend backends
152
+
153
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
154
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
155
+ *per pyramid level*.
156
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
157
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
158
+ Poisson is the safe default for single image inpainting because it reads
159
+ only the (valid) hole boundary; the compositor repairs the background under
160
+ the hole so no backend ever samples the object being removed.
161
+
162
+ ## Session persistence
163
+
164
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
165
+ removal is dispatched onto a background queue and run **under a lock** — never
166
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
167
+ empty session dirs left by aborted/crashed edits.
168
+
169
+ ## Beyond object removal
170
+
171
+ A handful of adjacent features share the same primitives as object removal
172
+ rather than each reimplementing their own:
173
+
174
+ - **`object_remove/cv/`** — reusable classic CV building blocks: ORB
175
+ (FAST + BRIEF + Hamming, also used by tier 2), Harris/Shi-Tomasi corners,
176
+ Canny edges, a distance transform, a separable max filter / non-max
177
+ suppression, a generic sigma-configurable Gaussian/box blur, nearest-opaque
178
+ fill, frequency separation.
179
+ - **`object_remove/effects/`** — `background_blur` (selection-distance
180
+ driven falloff, since no depth model ships) and
181
+ `FourPointPerspectiveCorrector` (4-point homography + inverse warp).
182
+ - **`fillengines/clone_stamp.py`** — manual clone stamp: a user-specified
183
+ source offset instead of a searched one, composing with `PatchCompositor`
184
+ like any other engine.
185
+ - **`fillengines/wire_removal.py`** — wire/cable removal: a *derived*
186
+ (Hessian ridge traced), not painted, mask acquisition model, feeding the
187
+ same fill engines used for object removal.
188
+
189
+ ## Status & scope
190
+
191
+ Implemented and tested: the full pipeline across all stages, CPU (NumPy) and
192
+ optional GPU (PyTorch: CUDA + Apple MPS) backends for every fill engine and
193
+ blender, plus the adjacent features above. Auto select is explicitly an
194
+ *interface + CPU fallback*: a real segmentation model can be dropped in
195
+ behind `AutoSelectModel`, which ships a working color flood fallback so tap
196
+ to select runs end to end without one.
197
+
198
+ ```
199
+ object_remove/
200
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
201
+ connected_components, auto_select
202
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler, torch_pyramid
203
+ fillengines/ patch_comparator (T1 comparator), primary/ (T1 general solver),
204
+ self_similar_shift (T2), patchmatch/ (T3),
205
+ clone_stamp, wire_removal
206
+ each fill engine has a *_gpu.py / torch_*.py sibling module
207
+ render/ multiband_blender(+_gpu), poisson_blender(+_gpu), compositor,
208
+ fused_patch_blend
209
+ cv/ orb, harris, canny, distance_transform, max_finder,
210
+ symmetric_convolution, box_filter, nearest_opaque,
211
+ frequency_separation
212
+ effects/ background_blur, perspective_correction
213
+ runtime/ device (GPU device resolution)
214
+ session/ undo_session_manager, empty_session_sweeper
215
+ pipeline/ remove_object_job
216
+ ```
@@ -0,0 +1,185 @@
1
+ # object_remove
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/0.2.1/)
4
+
5
+ A from scratch implementation of an on device object removal pipeline (see
6
+ [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
7
+ polygon/scanline mask layer, **four fill engines** of increasing
8
+ sophistication, **two interchangeable blend backends**, GPU acceleration
9
+ (CUDA + Apple MPS) that engages automatically when available, and a
10
+ locked/queued session persistence layer.
11
+
12
+ Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
13
+ Hamming matching, a quad-tree patch-grid solver, PatchMatch, Laplacian/Poisson
14
+ blending) — no OpenCV, no learned weights.
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ pip install object-remove
20
+ ```
21
+
22
+ ```python
23
+ import numpy as np
24
+ from object_remove import RemoveObjectJob, EngineTier
25
+
26
+ result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
27
+ progress=lambda stage, frac: ...)
28
+ removed = result.image # float32 RGB in [0, 1]
29
+ print(result.tier_used) # which engine AUTO picked
30
+ ```
31
+
32
+ `image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
33
+ nonzero marking the object to remove. Every engine and blender defaults to
34
+ `device="auto"`: use a GPU (CUDA on NVIDIA, MPS on Apple Silicon) when one is
35
+ available, otherwise the plain NumPy path — no separate install, no manual
36
+ opt in. Pass `"cuda"`/`"mps"`/`"cpu"` to force a specific torch backend, or
37
+ `device=None` to force plain NumPy regardless of hardware — see
38
+ [GPU acceleration](#gpu-acceleration) below.
39
+
40
+ **New to the package? Start with [`docs/USAGE.md`](docs/USAGE.md)** — it walks
41
+ through loading an image, building a mask three different ways (brush stroke,
42
+ polygon selection, tap-to-auto-select), refining it, running the removal, and
43
+ undo/session history, with runnable snippets for each.
44
+
45
+ To run from a source checkout instead of the installed package:
46
+
47
+ ```bash
48
+ pip install -r requirements.txt # numpy, scipy, Pillow, torch
49
+ PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
50
+ PYTHONPATH=. python -m pytest -q # tests
51
+ ```
52
+
53
+ ## Module layout
54
+
55
+ | Stage | Concern | This repo |
56
+ |---|---|---|
57
+ | A — mask | selection, brush, auto select | `object_remove/mask/` |
58
+ | B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
59
+ | C0 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
60
+ | C1 — general solver | quad-tree patch-grid solve | `fillengines/primary/` |
61
+ | C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
62
+ | C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
63
+ | E — blend | multi band + Poisson | `object_remove/render/` |
64
+ | F — session | undo / persistence | `object_remove/session/` |
65
+ | — GPU runtime | device resolution | `object_remove/runtime/device.py` |
66
+ | — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
67
+
68
+ ## The four fill engines
69
+
70
+ - **Tier 1 comparator — `SegmentAwarePatchFillEngine`** — greedy onion peel
71
+ exemplar fill scored by segment aware SSD with byte mask validity. Cheap,
72
+ correct on small holes; the low end / tiny hole fallback.
73
+ - **Tier 1 primary — `PrimaryFillEngine`** — the general purpose solver: a
74
+ quad-tree patch search (`fillengines/primary/patch_search_tree.py`) over a
75
+ **sparse grid of patch centres** (not a dense per-pixel field), solved in
76
+ onion peel order with adaptive, blur-tested scale selection instead of a
77
+ fixed pyramid formula.
78
+ - **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
79
+ FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
80
+ offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
81
+ a transpose trick.
82
+ - **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
83
+ (random init → propagation → random search, then patch voting reconstruction,
84
+ coarse to fine). Available for explicit selection.
85
+
86
+ `EngineTier.AUTO` sends truly tiny holes to tier 1 comparator, otherwise
87
+ tries the cheap tier 2 rigid shift first and falls back to tier 1 primary
88
+ when the shift's boundary continuity score is poor.
89
+
90
+ ## GPU acceleration
91
+
92
+ `torch` ships as a regular dependency — one `pip install object-remove` gets
93
+ both CPU and GPU support, nothing extra to install. Every fill engine and
94
+ both blenders accept a `device` kwarg, defaulting to `"auto"`: use a GPU
95
+ (CUDA on NVIDIA, MPS on Apple Silicon) when one is available, otherwise the
96
+ plain NumPy path. Explicit `"cuda"`/`"mps"`/`"cpu"` force that exact torch
97
+ backend (and raise clearly if it isn't available); `device=None` forces
98
+ plain NumPy regardless of hardware.
99
+
100
+ ```python
101
+ from object_remove.render.multiband_blender import MultiBandBlender
102
+ from object_remove.fillengines import PrimaryFillEngine
103
+
104
+ blender = MultiBandBlender(bands=3) # device="auto" by default
105
+ engine = PrimaryFillEngine(device=None) # force plain NumPy
106
+ ```
107
+
108
+ Notes on the GPU path:
109
+
110
+ - The multi-band blender is a straight port (pure array math). The Poisson
111
+ blender solves the identical linear system via matrix-free conjugate
112
+ gradient instead of an exact sparse solve. The PatchMatch solver uses
113
+ red-black checkerboard propagation instead of raster order, so GPU output
114
+ legitimately differs pixel for pixel from the CPU path (same quality bar,
115
+ different but equally valid traversal order).
116
+ - The torch path runs in **float32** throughout (Apple MPS has weak/no
117
+ float64 support) — a deliberate precision tradeoff vs. the plain NumPy
118
+ path's float64.
119
+
120
+ ## The two blend backends
121
+
122
+ - **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
123
+ kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
124
+ *per pyramid level*.
125
+ - **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
126
+ `multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
127
+ Poisson is the safe default for single image inpainting because it reads
128
+ only the (valid) hole boundary; the compositor repairs the background under
129
+ the hole so no backend ever samples the object being removed.
130
+
131
+ ## Session persistence
132
+
133
+ `SessionManager` gives each edit a session id and an RLE mask history. Directory
134
+ removal is dispatched onto a background queue and run **under a lock** — never
135
+ an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
136
+ empty session dirs left by aborted/crashed edits.
137
+
138
+ ## Beyond object removal
139
+
140
+ A handful of adjacent features share the same primitives as object removal
141
+ rather than each reimplementing their own:
142
+
143
+ - **`object_remove/cv/`** — reusable classic CV building blocks: ORB
144
+ (FAST + BRIEF + Hamming, also used by tier 2), Harris/Shi-Tomasi corners,
145
+ Canny edges, a distance transform, a separable max filter / non-max
146
+ suppression, a generic sigma-configurable Gaussian/box blur, nearest-opaque
147
+ fill, frequency separation.
148
+ - **`object_remove/effects/`** — `background_blur` (selection-distance
149
+ driven falloff, since no depth model ships) and
150
+ `FourPointPerspectiveCorrector` (4-point homography + inverse warp).
151
+ - **`fillengines/clone_stamp.py`** — manual clone stamp: a user-specified
152
+ source offset instead of a searched one, composing with `PatchCompositor`
153
+ like any other engine.
154
+ - **`fillengines/wire_removal.py`** — wire/cable removal: a *derived*
155
+ (Hessian ridge traced), not painted, mask acquisition model, feeding the
156
+ same fill engines used for object removal.
157
+
158
+ ## Status & scope
159
+
160
+ Implemented and tested: the full pipeline across all stages, CPU (NumPy) and
161
+ optional GPU (PyTorch: CUDA + Apple MPS) backends for every fill engine and
162
+ blender, plus the adjacent features above. Auto select is explicitly an
163
+ *interface + CPU fallback*: a real segmentation model can be dropped in
164
+ behind `AutoSelectModel`, which ships a working color flood fallback so tap
165
+ to select runs end to end without one.
166
+
167
+ ```
168
+ object_remove/
169
+ mask/ brush_rasterizer, polygon_selection, selection_enhancer,
170
+ connected_components, auto_select
171
+ pyramid/ image_pyramid, scale_scheduler, tile_scheduler, torch_pyramid
172
+ fillengines/ patch_comparator (T1 comparator), primary/ (T1 general solver),
173
+ self_similar_shift (T2), patchmatch/ (T3),
174
+ clone_stamp, wire_removal
175
+ each fill engine has a *_gpu.py / torch_*.py sibling module
176
+ render/ multiband_blender(+_gpu), poisson_blender(+_gpu), compositor,
177
+ fused_patch_blend
178
+ cv/ orb, harris, canny, distance_transform, max_finder,
179
+ symmetric_convolution, box_filter, nearest_opaque,
180
+ frequency_separation
181
+ effects/ background_blur, perspective_correction
182
+ runtime/ device (GPU device resolution)
183
+ session/ undo_session_manager, empty_session_sweeper
184
+ pipeline/ remove_object_job
185
+ ```
@@ -15,7 +15,7 @@ Public entry point::
15
15
  import importlib
16
16
  from typing import Any
17
17
 
18
- __version__ = "0.1.0"
18
+ __version__ = "0.2.1"
19
19
 
20
20
  # Lazy attribute map: name maps to (submodule, attribute). Loaded on first access so
21
21
  # importing any subpackage never eagerly drags in the whole tree.
@@ -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)