object-remove 0.1.0__py3-none-any.whl
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/__init__.py +37 -0
- object_remove/fillengines/__init__.py +20 -0
- object_remove/fillengines/patch_comparator.py +182 -0
- object_remove/fillengines/patchmatch/__init__.py +41 -0
- object_remove/fillengines/patchmatch/group_solver.py +127 -0
- object_remove/fillengines/patchmatch/patch.py +48 -0
- object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
- object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
- object_remove/fillengines/patchmatch/solver.py +152 -0
- object_remove/fillengines/self_similar_shift.py +368 -0
- object_remove/imageutil.py +59 -0
- object_remove/mask/__init__.py +22 -0
- object_remove/mask/auto_select.py +84 -0
- object_remove/mask/brush_rasterizer.py +97 -0
- object_remove/mask/connected_components.py +110 -0
- object_remove/mask/polygon_selection.py +219 -0
- object_remove/mask/selection_enhancer.py +127 -0
- object_remove/pipeline/__init__.py +7 -0
- object_remove/pipeline/remove_object_job.py +154 -0
- object_remove/py.typed +0 -0
- object_remove/pyramid/__init__.py +15 -0
- object_remove/pyramid/image_pyramid.py +101 -0
- object_remove/pyramid/scale_scheduler.py +56 -0
- object_remove/pyramid/tile_scheduler.py +80 -0
- object_remove/render/__init__.py +16 -0
- object_remove/render/compositor.py +62 -0
- object_remove/render/multiband_blender.py +75 -0
- object_remove/render/poisson_blender.py +132 -0
- object_remove/session/__init__.py +11 -0
- object_remove/session/empty_session_sweeper.py +49 -0
- object_remove/session/undo_session_manager.py +178 -0
- object_remove-0.1.0.dist-info/METADATA +130 -0
- object_remove-0.1.0.dist-info/RECORD +36 -0
- object_remove-0.1.0.dist-info/WHEEL +5 -0
- object_remove-0.1.0.dist-info/licenses/LICENSE +21 -0
- object_remove-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pipeline orchestrator — ``RemoveObjectJob``.
|
|
2
|
+
|
|
3
|
+
Wires Stage A (mask) to B (scheduling) to C (fill, engine tier selection) to
|
|
4
|
+
E (blend) to F (session), with progress reporting and cooperative cancellation.
|
|
5
|
+
|
|
6
|
+
Engine tier selection heuristic:
|
|
7
|
+
* tiny holes / low end path to tier 1 (segment aware CPU comparator)
|
|
8
|
+
* homogeneous repetitive texture to tier 2 (self similar rigid shift), the
|
|
9
|
+
cheap default fast path
|
|
10
|
+
* structured / complex holes to tier 3 (multi scale PatchMatch)
|
|
11
|
+
AUTO tries tier 2 first (cheap) and falls back to tier 3 when the rigid shift
|
|
12
|
+
continuity score across the hole boundary is poor.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Callable, List, Optional
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ..imageutil import as_float_rgb, rgb_to_gray
|
|
26
|
+
from ..mask.connected_components import split_components
|
|
27
|
+
from ..mask.selection_enhancer import SelectionEnhancer
|
|
28
|
+
from ..fillengines import (
|
|
29
|
+
SegmentAwarePatchFillEngine, SelfSimilarShiftEngine, PatchMatchEngine,
|
|
30
|
+
)
|
|
31
|
+
from ..render.compositor import PatchCompositor, BlendBackend
|
|
32
|
+
from ..session.undo_session_manager import SessionManager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class EngineTier(str, Enum):
|
|
36
|
+
AUTO = "auto"
|
|
37
|
+
TIER1_COMPARATOR = "tier1"
|
|
38
|
+
TIER2_SHIFT = "tier2"
|
|
39
|
+
TIER3_PATCHMATCH = "tier3"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
ProgressFn = Callable[[str, float], None]
|
|
43
|
+
CancelFn = Callable[[], bool]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class JobResult:
|
|
48
|
+
image: np.ndarray
|
|
49
|
+
tier_used: EngineTier
|
|
50
|
+
cancelled: bool = False
|
|
51
|
+
stages: List[str] = field(default_factory=list)
|
|
52
|
+
session_id: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RemoveObjectJob:
|
|
56
|
+
"""One object removal edit. Reusable across masks on the same image."""
|
|
57
|
+
|
|
58
|
+
LABEL = "Remove objects"
|
|
59
|
+
|
|
60
|
+
def __init__(self, image: np.ndarray,
|
|
61
|
+
manager: Optional[SessionManager] = None,
|
|
62
|
+
blend_backend: BlendBackend = BlendBackend.POISSON2,
|
|
63
|
+
tiny_hole_area: int = 64,
|
|
64
|
+
shift_continuity_threshold: float = 0.004):
|
|
65
|
+
self.image = as_float_rgb(image).astype(np.float64)
|
|
66
|
+
self.manager = manager
|
|
67
|
+
self.blend_backend = blend_backend
|
|
68
|
+
self.tiny_hole_area = tiny_hole_area
|
|
69
|
+
# tier 2 rigid shift is kept only if its border continuity SSD is below
|
|
70
|
+
# this; otherwise AUTO falls back to tier 3 PatchMatch.
|
|
71
|
+
self.shift_continuity_threshold = shift_continuity_threshold
|
|
72
|
+
self._enh = SelectionEnhancer()
|
|
73
|
+
|
|
74
|
+
def run(self, mask: np.ndarray, tier: EngineTier = EngineTier.AUTO,
|
|
75
|
+
refine_mask: bool = True, progress: Optional[ProgressFn] = None,
|
|
76
|
+
cancel: Optional[CancelFn] = None) -> JobResult:
|
|
77
|
+
stages: List[str] = []
|
|
78
|
+
last_frac = [0.0] # progress must never decrease, even across a
|
|
79
|
+
# tier 2 to tier 3 fallback that restarts fill at 0.
|
|
80
|
+
|
|
81
|
+
def report(stage: str, frac: float):
|
|
82
|
+
stages.append(stage) if stage not in stages else None
|
|
83
|
+
if progress is not None:
|
|
84
|
+
clamped_frac = max(last_frac[0], float(np.clip(frac, 0.0, 1.0)))
|
|
85
|
+
last_frac[0] = clamped_frac
|
|
86
|
+
progress(stage, clamped_frac)
|
|
87
|
+
|
|
88
|
+
def cancelled() -> bool:
|
|
89
|
+
return cancel is not None and cancel()
|
|
90
|
+
|
|
91
|
+
# Stage A: mask refinement
|
|
92
|
+
report("mask", 0.0)
|
|
93
|
+
hole = np.ascontiguousarray(mask > 0)
|
|
94
|
+
if refine_mask:
|
|
95
|
+
# Smooth jagged edges, but never drop user selected pixels — eroding
|
|
96
|
+
# the mask would leave slivers of the object unremoved. Union back
|
|
97
|
+
# with the original selection so refinement can only grow coverage.
|
|
98
|
+
hole = self._enh.denoise_and_smooth(hole, iterations=1) | (mask > 0)
|
|
99
|
+
if not hole.any():
|
|
100
|
+
return JobResult(self.image.copy(), tier, stages=stages)
|
|
101
|
+
|
|
102
|
+
# Stage F (open): session
|
|
103
|
+
session = None
|
|
104
|
+
if self.manager is not None:
|
|
105
|
+
session = self.manager.new_session()
|
|
106
|
+
session.record(hole, note=self.LABEL)
|
|
107
|
+
|
|
108
|
+
if cancelled():
|
|
109
|
+
return JobResult(self.image.copy(), tier, cancelled=True, stages=stages)
|
|
110
|
+
|
|
111
|
+
# Stage C: pick tier & fill
|
|
112
|
+
report("fill", 0.05)
|
|
113
|
+
filled, chosen = self._run_engine(tier, hole,
|
|
114
|
+
lambda f: report("fill", 0.05 + 0.8 * f),
|
|
115
|
+
cancelled)
|
|
116
|
+
if cancelled():
|
|
117
|
+
return JobResult(self.image.copy(), chosen, cancelled=True, stages=stages)
|
|
118
|
+
|
|
119
|
+
# Stage E: blend
|
|
120
|
+
report("blend", 0.9)
|
|
121
|
+
backend = self._backend_for_tier(chosen)
|
|
122
|
+
compositor = PatchCompositor(backend, bands=3, feather_radius=2.0)
|
|
123
|
+
out = compositor.composite(filled, self.image, hole)
|
|
124
|
+
|
|
125
|
+
report("done", 1.0)
|
|
126
|
+
return JobResult(out, chosen, stages=stages,
|
|
127
|
+
session_id=session.session_id if session else None)
|
|
128
|
+
|
|
129
|
+
def _run_engine(self, tier: EngineTier, hole: np.ndarray,
|
|
130
|
+
progress, cancelled):
|
|
131
|
+
"""Run a fill engine. For AUTO: truly tiny holes go to tier 1; otherwise
|
|
132
|
+
try the cheap tier 2 rigid shift first and fall back to tier 3
|
|
133
|
+
PatchMatch when its boundary continuity is poor."""
|
|
134
|
+
area = int(hole.sum())
|
|
135
|
+
|
|
136
|
+
if tier == EngineTier.TIER1_COMPARATOR or (
|
|
137
|
+
tier == EngineTier.AUTO and area <= self.tiny_hole_area):
|
|
138
|
+
tier1_engine = SegmentAwarePatchFillEngine(patch_radius=3)
|
|
139
|
+
return tier1_engine.fill(self.image, hole, progress=progress,
|
|
140
|
+
cancel=cancelled), EngineTier.TIER1_COMPARATOR
|
|
141
|
+
|
|
142
|
+
if tier in (EngineTier.TIER2_SHIFT, EngineTier.AUTO):
|
|
143
|
+
tier2_engine = SelfSimilarShiftEngine(margin=8)
|
|
144
|
+
out = tier2_engine.fill(self.image, hole, progress=progress, cancel=cancelled)
|
|
145
|
+
good = tier2_engine.last_border_score <= self.shift_continuity_threshold
|
|
146
|
+
if tier == EngineTier.TIER2_SHIFT or good:
|
|
147
|
+
return out, EngineTier.TIER2_SHIFT
|
|
148
|
+
# AUTO + poor continuity, fall back to the quality tier
|
|
149
|
+
tier3_engine = PatchMatchEngine(levels=4)
|
|
150
|
+
return tier3_engine.fill(self.image, hole, progress=progress,
|
|
151
|
+
cancel=cancelled), EngineTier.TIER3_PATCHMATCH
|
|
152
|
+
|
|
153
|
+
def _backend_for_tier(self, tier: EngineTier) -> BlendBackend:
|
|
154
|
+
return self.blend_backend
|
object_remove/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Stage B/D — multi scale (pyramid) and tile/slice scheduling.
|
|
2
|
+
|
|
3
|
+
Two independent scalability knobs: a coarse to fine pyramid for the
|
|
4
|
+
search/solve step, and tile/slice rendering for the compositing step on very
|
|
5
|
+
large images.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .image_pyramid import ImagePyramid, gaussian_blur, downsample, upsample
|
|
9
|
+
from .scale_scheduler import ScaleScheduler
|
|
10
|
+
from .tile_scheduler import TileScheduler, Tile
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ImagePyramid", "gaussian_blur", "downsample", "upsample",
|
|
14
|
+
"ScaleScheduler", "TileScheduler", "Tile",
|
|
15
|
+
]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Gaussian/Laplacian image pyramids.
|
|
2
|
+
|
|
3
|
+
Used by two independent consumers:
|
|
4
|
+
|
|
5
|
+
* the coarse to fine *search/solve* (fill engines), and
|
|
6
|
+
* the *blend* stage (Burt-Adelson Laplacian blending in the multi band blender).
|
|
7
|
+
|
|
8
|
+
The downsample blur uses a fixed **4 tap separable kernel** shared with the
|
|
9
|
+
multi band blender so the pyramid math is consistent everywhere it's used:
|
|
10
|
+
weights ``[0.15439, 0.15133, 0.14252, 0.12896]`` applied symmetrically as a
|
|
11
|
+
7 tap ``[w3, w2, w1, w0, w1, w2, w3]`` kernel.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
# Half kernel shared with MultiBandBlender.gaussian_blur.
|
|
21
|
+
_HALF = np.array([0.15439, 0.15133, 0.14252, 0.12896], dtype=np.float64)
|
|
22
|
+
# Full symmetric 7 tap kernel (renormalised so it sums to 1).
|
|
23
|
+
_KERNEL = np.concatenate([_HALF[:0:-1], _HALF])
|
|
24
|
+
_KERNEL = (_KERNEL / _KERNEL.sum()).astype(np.float64)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _convolve_sep(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
|
|
28
|
+
"""Separable convolution with edge padding. Works on 2D or 3D (H,W[,C])."""
|
|
29
|
+
k = kernel
|
|
30
|
+
r = len(k) // 2
|
|
31
|
+
img_f = img.astype(np.float64)
|
|
32
|
+
if img_f.ndim == 2:
|
|
33
|
+
img_f = img_f[:, :, None]
|
|
34
|
+
squeeze = True
|
|
35
|
+
else:
|
|
36
|
+
squeeze = False
|
|
37
|
+
# horizontal
|
|
38
|
+
padded = np.pad(img_f, ((0, 0), (r, r), (0, 0)), mode="edge")
|
|
39
|
+
horiz_out = np.zeros_like(img_f)
|
|
40
|
+
for i, w in enumerate(k):
|
|
41
|
+
horiz_out += w * padded[:, i:i + img_f.shape[1], :]
|
|
42
|
+
# vertical
|
|
43
|
+
padded = np.pad(horiz_out, ((r, r), (0, 0), (0, 0)), mode="edge")
|
|
44
|
+
result = np.zeros_like(img_f)
|
|
45
|
+
for i, w in enumerate(k):
|
|
46
|
+
result += w * padded[i:i + img_f.shape[0], :, :]
|
|
47
|
+
return result[:, :, 0] if squeeze else result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def gaussian_blur(img: np.ndarray) -> np.ndarray:
|
|
51
|
+
"""Blur with the exact binary kernel (no sigma parameter, by design)."""
|
|
52
|
+
return _convolve_sep(img, _KERNEL)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def downsample(img: np.ndarray) -> np.ndarray:
|
|
56
|
+
"""Blur then drop every other pixel (Burt-Adelson REDUCE)."""
|
|
57
|
+
return gaussian_blur(img)[::2, ::2]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def upsample(img: np.ndarray, out_shape: tuple[int, int]) -> np.ndarray:
|
|
61
|
+
"""Insert zeros then blur (Burt-Adelson EXPAND), resized to ``out_shape``."""
|
|
62
|
+
oh, ow = out_shape
|
|
63
|
+
if img.ndim == 3:
|
|
64
|
+
up = np.zeros((oh, ow, img.shape[2]), dtype=np.float64)
|
|
65
|
+
else:
|
|
66
|
+
up = np.zeros((oh, ow), dtype=np.float64)
|
|
67
|
+
up[::2, ::2] = img[: (oh + 1) // 2, : (ow + 1) // 2]
|
|
68
|
+
# weight *4 compensates the energy lost by zero insertion (2x per axis)
|
|
69
|
+
return _convolve_sep(up, _KERNEL) * 4.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ImagePyramid:
|
|
73
|
+
"""Gaussian pyramid + on demand Laplacian bands."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, image: np.ndarray, levels: int):
|
|
76
|
+
self.levels = max(1, levels)
|
|
77
|
+
self.gaussian: List[np.ndarray] = [image.astype(np.float64)]
|
|
78
|
+
for _ in range(self.levels - 1):
|
|
79
|
+
nxt = downsample(self.gaussian[-1])
|
|
80
|
+
if min(nxt.shape[:2]) < 2:
|
|
81
|
+
break
|
|
82
|
+
self.gaussian.append(nxt)
|
|
83
|
+
self.levels = len(self.gaussian)
|
|
84
|
+
|
|
85
|
+
def laplacian(self) -> List[np.ndarray]:
|
|
86
|
+
"""Laplacian bands: L[i] = G[i] - EXPAND(G[i+1]); top level is the residual."""
|
|
87
|
+
bands: List[np.ndarray] = []
|
|
88
|
+
for i in range(len(self.gaussian) - 1):
|
|
89
|
+
g = self.gaussian[i]
|
|
90
|
+
up = upsample(self.gaussian[i + 1], g.shape[:2])
|
|
91
|
+
bands.append(g - up)
|
|
92
|
+
bands.append(self.gaussian[-1])
|
|
93
|
+
return bands
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def collapse(bands: List[np.ndarray]) -> np.ndarray:
|
|
97
|
+
"""Reconstruct an image from Laplacian bands (upscale + add back up)."""
|
|
98
|
+
img = bands[-1]
|
|
99
|
+
for i in range(len(bands) - 2, -1, -1):
|
|
100
|
+
img = bands[i] + upsample(img, bands[i].shape[:2])
|
|
101
|
+
return img
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Coarse to fine scheduling.
|
|
2
|
+
|
|
3
|
+
Decides how many pyramid levels a solve should use from the hole size, and yields
|
|
4
|
+
levels coarse to fine so a fill engine can solve a small problem first and
|
|
5
|
+
upsample the result as an initialisation for the next finer level.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Iterator, List
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .image_pyramid import ImagePyramid, upsample
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ScaleScheduler:
|
|
20
|
+
max_levels: int = 5
|
|
21
|
+
min_coarse_dim: int = 16 # stop coarsening when the smaller image side hits this
|
|
22
|
+
|
|
23
|
+
def choose_levels(self, image_shape: tuple[int, int],
|
|
24
|
+
hole_bbox: tuple[int, int, int, int] | None = None) -> int:
|
|
25
|
+
"""Pick a level count. Bigger holes / images warrant more levels."""
|
|
26
|
+
h, w = image_shape[:2]
|
|
27
|
+
smaller = min(h, w)
|
|
28
|
+
levels = 1
|
|
29
|
+
dim = smaller
|
|
30
|
+
while dim > self.min_coarse_dim and levels < self.max_levels:
|
|
31
|
+
dim //= 2
|
|
32
|
+
levels += 1
|
|
33
|
+
if hole_bbox is not None:
|
|
34
|
+
x0, y0, x1, y1 = hole_bbox
|
|
35
|
+
hole = max(1, max(x1 - x0, y1 - y0))
|
|
36
|
+
# ensure the coarsest level shrinks the hole to a handful of pixels
|
|
37
|
+
levels_needed = 1
|
|
38
|
+
hole_dim = hole
|
|
39
|
+
while hole_dim > 8 and levels_needed < self.max_levels:
|
|
40
|
+
hole_dim //= 2
|
|
41
|
+
levels_needed += 1
|
|
42
|
+
levels = min(self.max_levels, max(levels, levels_needed))
|
|
43
|
+
return max(1, levels)
|
|
44
|
+
|
|
45
|
+
def build(self, image: np.ndarray, levels: int) -> ImagePyramid:
|
|
46
|
+
return ImagePyramid(image, levels)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def coarse_to_fine(pyramid: ImagePyramid) -> Iterator[int]:
|
|
50
|
+
"""Yield level indices from coarsest to finest."""
|
|
51
|
+
return iter(range(pyramid.levels - 1, -1, -1))
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def upscale_result(coarse: np.ndarray, fine_shape: tuple[int, int]) -> np.ndarray:
|
|
55
|
+
"""Upsample a coarse level solve to initialise the next finer level."""
|
|
56
|
+
return upsample(coarse, fine_shape[:2])
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Tile/slice rendering.
|
|
2
|
+
|
|
3
|
+
This is an *independent* knob from the resolution pyramid: it tiles the
|
|
4
|
+
*render* to cap peak memory on very large images, distinct from coarse to fine
|
|
5
|
+
search. It partitions a region into overlapping tiles that a compositor can
|
|
6
|
+
render one at a time and stitch, with the overlap feathered so tile seams don't
|
|
7
|
+
show.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Callable, Iterator, List, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Tile:
|
|
20
|
+
x0: int
|
|
21
|
+
y0: int
|
|
22
|
+
x1: int # exclusive
|
|
23
|
+
y1: int # exclusive
|
|
24
|
+
overlap: int # halo width included in [x0,x1)/[y0,y1) on each interior side
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class TileScheduler:
|
|
29
|
+
tile_size: int = 512
|
|
30
|
+
overlap: int = 32
|
|
31
|
+
|
|
32
|
+
def plan(self, width: int, height: int) -> List[Tile]:
|
|
33
|
+
"""Partition (width, height) into overlapping tiles."""
|
|
34
|
+
tiles: List[Tile] = []
|
|
35
|
+
step = max(1, self.tile_size - self.overlap)
|
|
36
|
+
ys = list(range(0, height, step))
|
|
37
|
+
xs = list(range(0, width, step))
|
|
38
|
+
for y in ys:
|
|
39
|
+
for x in xs:
|
|
40
|
+
x0 = max(0, x - self.overlap)
|
|
41
|
+
y0 = max(0, y - self.overlap)
|
|
42
|
+
x1 = min(width, x + self.tile_size)
|
|
43
|
+
y1 = min(height, y + self.tile_size)
|
|
44
|
+
tiles.append(Tile(x0, y0, x1, y1, self.overlap))
|
|
45
|
+
if x1 >= width:
|
|
46
|
+
break
|
|
47
|
+
if y1 >= height:
|
|
48
|
+
break
|
|
49
|
+
return tiles
|
|
50
|
+
|
|
51
|
+
def render_slices(self, width: int, height: int, channels: int,
|
|
52
|
+
render_tile: Callable[[Tile], np.ndarray]) -> np.ndarray:
|
|
53
|
+
"""Render each tile and blend overlaps with a linear feather (``renderSlices``).
|
|
54
|
+
|
|
55
|
+
``render_tile(tile)`` must return an array shaped
|
|
56
|
+
``(tile.y1-tile.y0, tile.x1-tile.x0, channels)``.
|
|
57
|
+
"""
|
|
58
|
+
acc = np.zeros((height, width, channels), dtype=np.float64)
|
|
59
|
+
wacc = np.zeros((height, width, 1), dtype=np.float64)
|
|
60
|
+
for tile in self.plan(width, height):
|
|
61
|
+
patch = render_tile(tile)
|
|
62
|
+
w = self._feather_weights(tile)
|
|
63
|
+
acc[tile.y0:tile.y1, tile.x0:tile.x1] += patch * w[:, :, None]
|
|
64
|
+
wacc[tile.y0:tile.y1, tile.x0:tile.x1] += w[:, :, None]
|
|
65
|
+
wacc[wacc == 0] = 1.0
|
|
66
|
+
return acc / wacc
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def _feather_weights(tile: Tile) -> np.ndarray:
|
|
70
|
+
h = tile.y1 - tile.y0
|
|
71
|
+
w = tile.x1 - tile.x0
|
|
72
|
+
overlap_px = max(1, tile.overlap)
|
|
73
|
+
weights_y = np.ones(h)
|
|
74
|
+
weights_x = np.ones(w)
|
|
75
|
+
ramp = (np.arange(overlap_px) + 1) / (overlap_px + 1)
|
|
76
|
+
weights_y[:overlap_px] = np.minimum(weights_y[:overlap_px], ramp)
|
|
77
|
+
weights_y[-overlap_px:] = np.minimum(weights_y[-overlap_px:], ramp[::-1])
|
|
78
|
+
weights_x[:overlap_px] = np.minimum(weights_x[:overlap_px], ramp)
|
|
79
|
+
weights_x[-overlap_px:] = np.minimum(weights_x[-overlap_px:], ramp[::-1])
|
|
80
|
+
return np.outer(weights_y, weights_x)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Stage E — render & blend.
|
|
2
|
+
|
|
3
|
+
Two interchangeable compositing backends:
|
|
4
|
+
:class:`MultiBandBlender` (fixed kernel, per level mask feather) and
|
|
5
|
+
:class:`PoissonBlend` / :class:`PoissonBlend2` (gradient domain). Selected
|
|
6
|
+
via :class:`PatchCompositor`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .multiband_blender import MultiBandBlender
|
|
10
|
+
from .poisson_blender import PoissonBlend, PoissonBlend2
|
|
11
|
+
from .compositor import PatchCompositor, BlendBackend
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MultiBandBlender", "PoissonBlend", "PoissonBlend2",
|
|
15
|
+
"PatchCompositor", "BlendBackend",
|
|
16
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Patch compositor — selects a blend backend and applies it.
|
|
2
|
+
|
|
3
|
+
Multi band and Poisson blending are interchangeable finishing steps chosen
|
|
4
|
+
per engine (tier 2 defaults to multi band, with an optional Poisson pass;
|
|
5
|
+
tier 3 defaults to Poisson). This compositor exposes that choice.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from ..imageutil import as_float_rgb
|
|
15
|
+
from ..mask.selection_enhancer import SelectionEnhancer
|
|
16
|
+
from .multiband_blender import MultiBandBlender
|
|
17
|
+
from .poisson_blender import PoissonBlend, PoissonBlend2
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BlendBackend(str, Enum):
|
|
21
|
+
MULTIBAND = "multiband"
|
|
22
|
+
POISSON = "poisson"
|
|
23
|
+
POISSON2 = "poisson2"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PatchCompositor:
|
|
27
|
+
def __init__(self, backend: BlendBackend = BlendBackend.MULTIBAND,
|
|
28
|
+
bands: int = 3, feather_radius: float = 2.0,
|
|
29
|
+
poisson_sigma: float = 0.0, poisson_multiplier: float = 1.0):
|
|
30
|
+
self.backend = backend
|
|
31
|
+
self.bands = bands
|
|
32
|
+
self.feather_radius = feather_radius
|
|
33
|
+
self.poisson_sigma = poisson_sigma
|
|
34
|
+
self.poisson_multiplier = poisson_multiplier
|
|
35
|
+
self.enhancer = SelectionEnhancer()
|
|
36
|
+
|
|
37
|
+
def composite(self, filled: np.ndarray, original: np.ndarray,
|
|
38
|
+
hole: np.ndarray) -> np.ndarray:
|
|
39
|
+
"""Blend the ``filled`` result into the ``original`` over the ``hole``."""
|
|
40
|
+
filled = as_float_rgb(filled).astype(np.float64)
|
|
41
|
+
original = as_float_rgb(original).astype(np.float64)
|
|
42
|
+
hole = np.ascontiguousarray(hole > 0)
|
|
43
|
+
|
|
44
|
+
# The pixels *under* the hole in ``original`` are the object being removed
|
|
45
|
+
# (or garbage) — no blend backend may ever read them. Repair the
|
|
46
|
+
# background with the fill first, so both the Poisson and multi band
|
|
47
|
+
# passes see a valid target everywhere.
|
|
48
|
+
safe_bg = original.copy()
|
|
49
|
+
safe_bg[hole] = filled[hole]
|
|
50
|
+
|
|
51
|
+
if self.backend == BlendBackend.MULTIBAND:
|
|
52
|
+
# feather strictly outward (hole stays fully filled)
|
|
53
|
+
soft = self.enhancer.soften(hole, radius=self.feather_radius, iterations=1)
|
|
54
|
+
soft = np.maximum(soft, hole.astype(np.float64))
|
|
55
|
+
return MultiBandBlender(self.bands).pyramid_blend(
|
|
56
|
+
filled, safe_bg, soft)
|
|
57
|
+
if self.backend == BlendBackend.POISSON2:
|
|
58
|
+
return PoissonBlend2(self.poisson_sigma,
|
|
59
|
+
self.poisson_multiplier).blend(
|
|
60
|
+
filled, safe_bg, hole)
|
|
61
|
+
return PoissonBlend(self.poisson_sigma, self.poisson_multiplier,
|
|
62
|
+
algo_n=1).blend(filled, safe_bg, hole)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Multi band (Laplacian pyramid) blending.
|
|
2
|
+
|
|
3
|
+
* the per level blur is a **fixed 4 tap separable kernel**, weights
|
|
4
|
+
``[0.15439, 0.15133, 0.14252, 0.12896]``;
|
|
5
|
+
* Laplacian bands are formed by downscale to upscale to subtract (textbook
|
|
6
|
+
Burt-Adelson);
|
|
7
|
+
* per band blending is a **masked lerp** before collapse;
|
|
8
|
+
* the **mask itself is blurred per pyramid level** with the same kernel before
|
|
9
|
+
being used as blend weights — feathering happens per level, not once at full
|
|
10
|
+
res. That subtlety is what removes the seam without softening real detail.
|
|
11
|
+
|
|
12
|
+
Band count is a runtime parameter, defaulted low since the tier 2 caller
|
|
13
|
+
passes a small fixed level count.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ..pyramid.image_pyramid import ImagePyramid, gaussian_blur, upsample, downsample
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MultiBandBlender:
|
|
24
|
+
def __init__(self, bands: int = 3):
|
|
25
|
+
self.bands = max(1, bands)
|
|
26
|
+
|
|
27
|
+
def pyramid_blend(self, foreground: np.ndarray, background: np.ndarray,
|
|
28
|
+
mask: np.ndarray) -> np.ndarray:
|
|
29
|
+
"""Blend ``foreground`` into ``background`` where ``mask`` ~ 1.
|
|
30
|
+
|
|
31
|
+
``mask`` may be soft (float in [0, 1]) or boolean. Returns float RGB.
|
|
32
|
+
"""
|
|
33
|
+
fg = foreground.astype(np.float64)
|
|
34
|
+
bg = background.astype(np.float64)
|
|
35
|
+
m = mask.astype(np.float64)
|
|
36
|
+
if m.ndim == 2:
|
|
37
|
+
m = m[:, :, None]
|
|
38
|
+
|
|
39
|
+
levels = self.bands
|
|
40
|
+
fg_lap = ImagePyramid(fg, levels).laplacian()
|
|
41
|
+
bg_lap = ImagePyramid(bg, levels).laplacian()
|
|
42
|
+
# Gaussian pyramid of the mask (per level blur = per level feather)
|
|
43
|
+
mask_gauss = ImagePyramid(m, levels).gaussian
|
|
44
|
+
|
|
45
|
+
blended_bands = []
|
|
46
|
+
for fg_band, bg_band, mask_band in zip(fg_lap, bg_lap, mask_gauss):
|
|
47
|
+
mask_band_resized = (mask_band if mask_band.shape[:2] == fg_band.shape[:2]
|
|
48
|
+
else _resize(mask_band, fg_band.shape[:2]))
|
|
49
|
+
blended_bands.append(self._blend(fg_band, bg_band, mask_band_resized))
|
|
50
|
+
out = ImagePyramid.collapse(blended_bands)
|
|
51
|
+
return np.clip(out, 0.0, 1.0)
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _blend(fg_band: np.ndarray, bg_band: np.ndarray, w: np.ndarray) -> np.ndarray:
|
|
55
|
+
"""Masked lerp: w*fg + (1-w)*bg."""
|
|
56
|
+
return w * fg_band + (1.0 - w) * bg_band
|
|
57
|
+
|
|
58
|
+
# exposed as a method too, for direct testing convenience
|
|
59
|
+
@staticmethod
|
|
60
|
+
def gaussian_blur(img: np.ndarray) -> np.ndarray:
|
|
61
|
+
return gaussian_blur(img)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def mix_frequencies(img: np.ndarray) -> np.ndarray:
|
|
65
|
+
"""One Laplacian band: img - EXPAND(REDUCE(img))."""
|
|
66
|
+
down = downsample(img)
|
|
67
|
+
up = upsample(down, img.shape[:2])
|
|
68
|
+
return img - up
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resize(m: np.ndarray, shape) -> np.ndarray:
|
|
72
|
+
h, w = shape
|
|
73
|
+
ys = np.linspace(0, m.shape[0] - 1, h).astype(int)
|
|
74
|
+
xs = np.linspace(0, m.shape[1] - 1, w).astype(int)
|
|
75
|
+
return m[np.ix_(ys, xs)]
|