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.
- object_remove-0.2.0/PKG-INFO +214 -0
- object_remove-0.2.0/README.md +183 -0
- object_remove-0.2.0/object_remove/cv/__init__.py +30 -0
- object_remove-0.2.0/object_remove/cv/box_filter.py +16 -0
- object_remove-0.2.0/object_remove/cv/canny.py +103 -0
- object_remove-0.2.0/object_remove/cv/distance_transform.py +39 -0
- object_remove-0.2.0/object_remove/cv/frequency_separation.py +34 -0
- object_remove-0.2.0/object_remove/cv/harris.py +63 -0
- object_remove-0.2.0/object_remove/cv/max_finder.py +61 -0
- object_remove-0.2.0/object_remove/cv/nearest_opaque.py +50 -0
- object_remove-0.2.0/object_remove/cv/orb.py +124 -0
- object_remove-0.2.0/object_remove/cv/symmetric_convolution.py +51 -0
- object_remove-0.2.0/object_remove/effects/__init__.py +17 -0
- object_remove-0.2.0/object_remove/effects/background_blur.py +57 -0
- object_remove-0.2.0/object_remove/effects/perspective_correction.py +168 -0
- object_remove-0.2.0/object_remove/fillengines/__init__.py +32 -0
- object_remove-0.2.0/object_remove/fillengines/clone_stamp.py +53 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patch_comparator.py +10 -1
- object_remove-0.2.0/object_remove/fillengines/patch_comparator_gpu.py +169 -0
- object_remove-0.2.0/object_remove/fillengines/patchmatch/__init__.py +62 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/group_solver.py +22 -6
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch_scorer.py +2 -2
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch_search_index.py +13 -12
- object_remove-0.2.0/object_remove/fillengines/patchmatch/torch_solver.py +241 -0
- object_remove-0.2.0/object_remove/fillengines/primary/__init__.py +143 -0
- object_remove-0.2.0/object_remove/fillengines/primary/patch_grid_solver.py +194 -0
- object_remove-0.2.0/object_remove/fillengines/primary/patch_grid_solver_gpu.py +182 -0
- object_remove-0.2.0/object_remove/fillengines/primary/quad_tree_index.py +136 -0
- object_remove-0.2.0/object_remove/fillengines/primary/renderer.py +35 -0
- object_remove-0.2.0/object_remove/fillengines/primary/scoring.py +47 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/self_similar_shift.py +38 -117
- object_remove-0.2.0/object_remove/fillengines/self_similar_shift_gpu.py +96 -0
- object_remove-0.2.0/object_remove/fillengines/wire_removal.py +172 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/auto_select.py +3 -3
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/polygon_selection.py +6 -6
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/selection_enhancer.py +20 -20
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pipeline/remove_object_job.py +27 -10
- object_remove-0.2.0/object_remove/pyramid/scale_scheduler.py +98 -0
- object_remove-0.2.0/object_remove/pyramid/tile_scheduler.py +150 -0
- object_remove-0.2.0/object_remove/pyramid/torch_pyramid.py +89 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/__init__.py +5 -1
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/compositor.py +8 -5
- object_remove-0.2.0/object_remove/render/fused_patch_blend.py +74 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/multiband_blender.py +15 -1
- object_remove-0.2.0/object_remove/render/multiband_blender_gpu.py +50 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/render/poisson_blender.py +37 -24
- object_remove-0.2.0/object_remove/render/poisson_blender_gpu.py +188 -0
- object_remove-0.2.0/object_remove/runtime/__init__.py +5 -0
- object_remove-0.2.0/object_remove/runtime/device.py +55 -0
- object_remove-0.2.0/object_remove.egg-info/PKG-INFO +214 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/SOURCES.txt +38 -1
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/requires.txt +4 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/pyproject.toml +5 -4
- object_remove-0.2.0/tests/test_clone_stamp.py +33 -0
- object_remove-0.2.0/tests/test_cv.py +111 -0
- object_remove-0.2.0/tests/test_effects.py +37 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_fillengines.py +14 -7
- object_remove-0.2.0/tests/test_gpu.py +183 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_mask.py +5 -5
- object_remove-0.2.0/tests/test_perspective_correction.py +63 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_pipeline.py +6 -6
- object_remove-0.2.0/tests/test_primary_fill_engine.py +67 -0
- object_remove-0.2.0/tests/test_pyramid.py +96 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/tests/test_render.py +28 -4
- object_remove-0.2.0/tests/test_wire_removal.py +63 -0
- object_remove-0.1.0/PKG-INFO +0 -130
- object_remove-0.1.0/README.md +0 -102
- object_remove-0.1.0/object_remove/fillengines/__init__.py +0 -20
- object_remove-0.1.0/object_remove/fillengines/patchmatch/__init__.py +0 -41
- object_remove-0.1.0/object_remove/pyramid/scale_scheduler.py +0 -56
- object_remove-0.1.0/object_remove/pyramid/tile_scheduler.py +0 -80
- object_remove-0.1.0/object_remove.egg-info/PKG-INFO +0 -130
- object_remove-0.1.0/tests/test_pyramid.py +0 -42
- {object_remove-0.1.0 → object_remove-0.2.0}/LICENSE +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/__init__.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/patch.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/fillengines/patchmatch/solver.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/imageutil.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/__init__.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/brush_rasterizer.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/mask/connected_components.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pipeline/__init__.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/py.typed +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pyramid/__init__.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/pyramid/image_pyramid.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/__init__.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/empty_session_sweeper.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove/session/undo_session_manager.py +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/dependency_links.txt +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/object_remove.egg-info/top_level.txt +0 -0
- {object_remove-0.1.0 → object_remove-0.2.0}/setup.cfg +0 -0
- {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)
|