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.
Files changed (36) hide show
  1. object_remove/__init__.py +37 -0
  2. object_remove/fillengines/__init__.py +20 -0
  3. object_remove/fillengines/patch_comparator.py +182 -0
  4. object_remove/fillengines/patchmatch/__init__.py +41 -0
  5. object_remove/fillengines/patchmatch/group_solver.py +127 -0
  6. object_remove/fillengines/patchmatch/patch.py +48 -0
  7. object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
  8. object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
  9. object_remove/fillengines/patchmatch/solver.py +152 -0
  10. object_remove/fillengines/self_similar_shift.py +368 -0
  11. object_remove/imageutil.py +59 -0
  12. object_remove/mask/__init__.py +22 -0
  13. object_remove/mask/auto_select.py +84 -0
  14. object_remove/mask/brush_rasterizer.py +97 -0
  15. object_remove/mask/connected_components.py +110 -0
  16. object_remove/mask/polygon_selection.py +219 -0
  17. object_remove/mask/selection_enhancer.py +127 -0
  18. object_remove/pipeline/__init__.py +7 -0
  19. object_remove/pipeline/remove_object_job.py +154 -0
  20. object_remove/py.typed +0 -0
  21. object_remove/pyramid/__init__.py +15 -0
  22. object_remove/pyramid/image_pyramid.py +101 -0
  23. object_remove/pyramid/scale_scheduler.py +56 -0
  24. object_remove/pyramid/tile_scheduler.py +80 -0
  25. object_remove/render/__init__.py +16 -0
  26. object_remove/render/compositor.py +62 -0
  27. object_remove/render/multiband_blender.py +75 -0
  28. object_remove/render/poisson_blender.py +132 -0
  29. object_remove/session/__init__.py +11 -0
  30. object_remove/session/empty_session_sweeper.py +49 -0
  31. object_remove/session/undo_session_manager.py +178 -0
  32. object_remove-0.1.0.dist-info/METADATA +130 -0
  33. object_remove-0.1.0.dist-info/RECORD +36 -0
  34. object_remove-0.1.0.dist-info/WHEEL +5 -0
  35. object_remove-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. 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)]