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,97 @@
1
+ """Brush stroke to coverage mask.
2
+
3
+ Converts a polyline of stamp centres (a user's drag) plus a radius into a
4
+ coverage mask by stamping capsule (round cap) segments. Also emits an
5
+ approximating polygon so the result can feed :class:`PolygonSelection` when the
6
+ caller wants the exact polygon representation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import List, Sequence, Tuple
12
+
13
+ import numpy as np
14
+
15
+ Point = Tuple[float, float]
16
+
17
+
18
+ class BrushRasterizer:
19
+ def __init__(self, width: int, height: int):
20
+ self.width = width
21
+ self.height = height
22
+
23
+ def stamp_stroke(self, points: Sequence[Point], radius: float) -> np.ndarray:
24
+ """Rasterise a brush drag as a coverage mask of round capped segments."""
25
+ mask = np.zeros((self.height, self.width), dtype=bool)
26
+ pts = [tuple(map(float, p)) for p in points]
27
+ if not pts:
28
+ return mask
29
+ if len(pts) == 1:
30
+ self._stamp_disk(mask, pts[0], radius)
31
+ return mask
32
+ for start, end in zip(pts[:-1], pts[1:]):
33
+ self._stamp_capsule(mask, start, end, radius)
34
+ return mask
35
+
36
+ def _stamp_disk(self, mask: np.ndarray, center: Point, r: float) -> None:
37
+ cx, cy = center
38
+ x0 = max(0, int(np.floor(cx - r)))
39
+ x1 = min(self.width - 1, int(np.ceil(cx + r)))
40
+ y0 = max(0, int(np.floor(cy - r)))
41
+ y1 = min(self.height - 1, int(np.ceil(cy + r)))
42
+ if x1 < x0 or y1 < y0:
43
+ return
44
+ yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
45
+ inside = (xx - cx) ** 2 + (yy - cy) ** 2 <= r * r
46
+ mask[y0:y1 + 1, x0:x1 + 1] |= inside
47
+
48
+ def _stamp_capsule(self, mask: np.ndarray, start: Point, end: Point, r: float) -> None:
49
+ ax, ay = start
50
+ bx, by = end
51
+ x0 = max(0, int(np.floor(min(ax, bx) - r)))
52
+ x1 = min(self.width - 1, int(np.ceil(max(ax, bx) + r)))
53
+ y0 = max(0, int(np.floor(min(ay, by) - r)))
54
+ y1 = min(self.height - 1, int(np.ceil(max(ay, by) + r)))
55
+ if x1 < x0 or y1 < y0:
56
+ return
57
+ yy, xx = np.mgrid[y0:y1 + 1, x0:x1 + 1]
58
+ dx, dy = bx - ax, by - ay
59
+ seg_len2 = dx * dx + dy * dy
60
+ if seg_len2 == 0:
61
+ self._stamp_disk(mask, start, r)
62
+ return
63
+ t = ((xx - ax) * dx + (yy - ay) * dy) / seg_len2
64
+ t = np.clip(t, 0.0, 1.0)
65
+ px = ax + t * dx
66
+ py = ay + t * dy
67
+ dist2 = (xx - px) ** 2 + (yy - py) ** 2
68
+ mask[y0:y1 + 1, x0:x1 + 1] |= dist2 <= r * r
69
+
70
+ @staticmethod
71
+ def stroke_to_polygon(points: Sequence[Point], radius: float, segments: int = 8) -> List[Point]:
72
+ """Approximate a capsule chain as a single closed polygon outline.
73
+
74
+ Useful when the caller wants the exact polygon representation to hand to
75
+ :class:`PolygonSelection` rather than a raster. Builds left/right offset
76
+ curves plus rounded end caps.
77
+ """
78
+ pts = np.asarray(points, dtype=np.float64)
79
+ if len(pts) == 1:
80
+ c = pts[0]
81
+ ang = np.linspace(0, 2 * np.pi, max(8, segments * 4), endpoint=False)
82
+ return [(float(c[0] + radius * np.cos(a)), float(c[1] + radius * np.sin(a))) for a in ang]
83
+ left: List[Point] = []
84
+ right: List[Point] = []
85
+ for i in range(len(pts) - 1):
86
+ start, end = pts[i], pts[i + 1]
87
+ seg_vec = end - start
88
+ normal = np.array([-seg_vec[1], seg_vec[0]])
89
+ norm = np.hypot(*normal)
90
+ if norm == 0:
91
+ continue
92
+ normal = normal / norm * radius
93
+ left.append((start[0] + normal[0], start[1] + normal[1]))
94
+ left.append((end[0] + normal[0], end[1] + normal[1]))
95
+ right.append((start[0] - normal[0], start[1] - normal[1]))
96
+ right.append((end[0] - normal[0], end[1] - normal[1]))
97
+ return left + right[::-1]
@@ -0,0 +1,110 @@
1
+ """Connected component labelling.
2
+
3
+ Labelling itself is a from scratch two pass union find (no
4
+ ``scipy.ndimage.label``), and the per region *extraction* step is fanned out
5
+ across a bounded thread pool so splitting a selection into its components
6
+ stays fast on large masks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from typing import List, Tuple
13
+
14
+ import numpy as np
15
+
16
+
17
+ class _UnionFind:
18
+ __slots__ = ("parent",)
19
+
20
+ def __init__(self, n: int):
21
+ self.parent = list(range(n))
22
+
23
+ def find(self, x: int) -> int:
24
+ root = x
25
+ while self.parent[root] != root:
26
+ root = self.parent[root]
27
+ while self.parent[x] != root:
28
+ self.parent[x], x = root, self.parent[x]
29
+ return root
30
+
31
+ def union(self, a: int, b: int) -> None:
32
+ ra, rb = self.find(a), self.find(b)
33
+ if ra != rb:
34
+ self.parent[max(ra, rb)] = min(ra, rb)
35
+
36
+
37
+ def label_components(mask: np.ndarray, connectivity: int = 8) -> Tuple[np.ndarray, int]:
38
+ """Two pass union find labelling. Returns (labels, count).
39
+
40
+ ``labels`` is int32, 0 = background, 1..count = components.
41
+ """
42
+ mask = np.ascontiguousarray(mask > 0)
43
+ h, w = mask.shape
44
+ labels = np.zeros((h, w), dtype=np.int32)
45
+ uf = _UnionFind(1)
46
+ next_label = 1
47
+
48
+ for y in range(h):
49
+ row = mask[y]
50
+ for x in range(w):
51
+ if not row[x]:
52
+ continue
53
+ neighbours: List[int] = []
54
+ if x > 0 and mask[y, x - 1]:
55
+ neighbours.append(labels[y, x - 1])
56
+ if y > 0 and mask[y - 1, x]:
57
+ neighbours.append(labels[y - 1, x])
58
+ if connectivity == 8 and y > 0:
59
+ if x > 0 and mask[y - 1, x - 1]:
60
+ neighbours.append(labels[y - 1, x - 1])
61
+ if x < w - 1 and mask[y - 1, x + 1]:
62
+ neighbours.append(labels[y - 1, x + 1])
63
+ if not neighbours:
64
+ labels[y, x] = next_label
65
+ uf.parent.append(next_label)
66
+ next_label += 1
67
+ else:
68
+ min_label = min(neighbours)
69
+ labels[y, x] = min_label
70
+ for nb in neighbours:
71
+ uf.union(min_label, nb)
72
+
73
+ # second pass: flatten to contiguous labels
74
+ remap: dict[int, int] = {}
75
+ count = 0
76
+ for y in range(h):
77
+ for x in range(w):
78
+ lab = labels[y, x]
79
+ if lab == 0:
80
+ continue
81
+ root = uf.find(lab)
82
+ if root not in remap:
83
+ count += 1
84
+ remap[root] = count
85
+ labels[y, x] = remap[root]
86
+ return labels, count
87
+
88
+
89
+ def split_components(mask: np.ndarray, max_selections: int = 0,
90
+ min_area: int = 1, workers: int = 4) -> List[np.ndarray]:
91
+ """Split a mask into per component boolean masks.
92
+
93
+ Extraction is fanned across a bounded worker pool. ``max_selections`` (>0)
94
+ keeps only the largest N components; ``min_area`` drops specks.
95
+ """
96
+ labels, n = label_components(mask)
97
+ if n == 0:
98
+ return []
99
+
100
+ def extract(lab: int) -> np.ndarray:
101
+ return labels == lab
102
+
103
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
104
+ comps = list(ex.map(extract, range(1, n + 1)))
105
+
106
+ comps = [c for c in comps if int(c.sum()) >= min_area]
107
+ comps.sort(key=lambda c: int(c.sum()), reverse=True)
108
+ if max_selections and max_selections > 0:
109
+ comps = comps[:max_selections]
110
+ return comps
@@ -0,0 +1,219 @@
1
+ """Polygon/scanline selection model.
2
+
3
+ The selection is built from signed polygons, and its boundary geometry can be
4
+ queried as unordered boundary points or ordered boundary cycles. The raster
5
+ mask is a cached derivative, not the primary store.
6
+
7
+ This class keeps a list of *signed* polygons (positive = add, negative =
8
+ subtract). Scanlines and the raster mask are generated on demand via an
9
+ even odd with sign coverage test evaluated per pixel row (scanline fill).
10
+ Boolean ops (union of brush strokes, subtract of corrections) are therefore
11
+ exact and resolution independent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from typing import Iterable, List, Sequence, Tuple
18
+
19
+ import numpy as np
20
+
21
+ Point = Tuple[float, float]
22
+
23
+
24
+ @dataclass
25
+ class _Poly:
26
+ points: np.ndarray # (N, 2) float, (x, y)
27
+ add: bool # True => add region, False => subtract region
28
+
29
+
30
+ @dataclass
31
+ class PolygonSelection:
32
+ """A selection as a set of signed polygons over a fixed canvas size."""
33
+
34
+ width: int
35
+ height: int
36
+ polys: List[_Poly] = field(default_factory=list)
37
+
38
+ def add_poly(self, points: Sequence[Point]) -> "PolygonSelection":
39
+ """Add a filled polygon to the selection."""
40
+ self.polys.append(_Poly(np.asarray(points, dtype=np.float64), add=True))
41
+ return self
42
+
43
+ def remove_poly(self, points: Sequence[Point]) -> "PolygonSelection":
44
+ """Subtract a polygon from the selection."""
45
+ self.polys.append(_Poly(np.asarray(points, dtype=np.float64), add=False))
46
+ return self
47
+
48
+ @classmethod
49
+ def from_mask(cls, mask: np.ndarray) -> "PolygonSelection":
50
+ """Build a selection from a raster mask by tracing its boundary.
51
+
52
+ We keep the raster as the source of truth here (caller already has a
53
+ raster, e.g. from a brush rasteriser) but wrap it so the polygon API is
54
+ uniform. Boundary tracing is used for :meth:`get_boundary_cycles`.
55
+ """
56
+ h, w = mask.shape
57
+ sel = cls(width=w, height=h)
58
+ sel._raster_override = (mask > 0)
59
+ return sel
60
+
61
+ _raster_override: np.ndarray | None = field(default=None, repr=False)
62
+
63
+ def generate_scanlines(self) -> List[Tuple[int, int, int]]:
64
+ """Return spans as ``(y, x_start, x_end_exclusive)`` covering the selection."""
65
+ mask = self.to_mask()
66
+ spans: List[Tuple[int, int, int]] = []
67
+ for y in range(self.height):
68
+ row = mask[y]
69
+ if not row.any():
70
+ continue
71
+ idx = np.flatnonzero(np.diff(np.concatenate(([0], row.view(np.int8), [0]))))
72
+ for a, b in zip(idx[0::2], idx[1::2]):
73
+ spans.append((y, int(a), int(b)))
74
+ return spans
75
+
76
+ def to_mask(self) -> np.ndarray:
77
+ """Rasterise the signed polygon set to a boolean mask (cached derivative)."""
78
+ if self._raster_override is not None:
79
+ return self._raster_override
80
+ mask = np.zeros((self.height, self.width), dtype=bool)
81
+ for poly in self.polys:
82
+ filled = _rasterize_polygon(poly.points, self.width, self.height)
83
+ if poly.add:
84
+ mask |= filled
85
+ else:
86
+ mask &= ~filled
87
+ return mask
88
+
89
+ def union(self, other: "PolygonSelection") -> "PolygonSelection":
90
+ self.polys.extend(other.polys)
91
+ return self
92
+
93
+ def subtract(self, other: "PolygonSelection") -> "PolygonSelection":
94
+ for p in other.polys:
95
+ self.polys.append(_Poly(p.points, add=not p.add))
96
+ return self
97
+
98
+ def get_total_boundary_points(self) -> int:
99
+ return sum(len(c) for c in self.get_boundary_cycles())
100
+
101
+ def get_unordered_boundary_points(self) -> np.ndarray:
102
+ """All boundary pixels as an (N, 2) array of (x, y), in no particular order."""
103
+ mask = self.to_mask()
104
+ eroded = _erode(mask)
105
+ ys, xs = np.nonzero(mask & ~eroded)
106
+ return np.stack([xs, ys], axis=1)
107
+
108
+ def get_boundary_cycles(self) -> List[np.ndarray]:
109
+ """Ordered boundary loops, one per connected component."""
110
+ from .connected_components import label_components
111
+
112
+ labels, n = label_components(self.to_mask())
113
+ cycles: List[np.ndarray] = []
114
+ for lab in range(1, n + 1):
115
+ comp = labels == lab
116
+ cycle = _trace_boundary(comp)
117
+ if len(cycle):
118
+ cycles.append(cycle)
119
+ return cycles
120
+
121
+ def is_empty(self) -> bool:
122
+ return not self.to_mask().any()
123
+
124
+ def copy(self) -> "PolygonSelection":
125
+ new = PolygonSelection(self.width, self.height,
126
+ [_Poly(p.points.copy(), p.add) for p in self.polys])
127
+ if self._raster_override is not None:
128
+ new._raster_override = self._raster_override.copy()
129
+ return new
130
+
131
+
132
+ def _rasterize_polygon(points: np.ndarray, width: int, height: int) -> np.ndarray:
133
+ """Even odd scanline polygon fill. ``points`` is (N, 2) as (x, y)."""
134
+ mask = np.zeros((height, width), dtype=bool)
135
+ n = len(points)
136
+ if n < 3:
137
+ return mask
138
+ xs = points[:, 0]
139
+ ys = points[:, 1]
140
+ y_min = max(0, int(np.floor(ys.min())))
141
+ y_max = min(height - 1, int(np.ceil(ys.max())))
142
+ for y in range(y_min, y_max + 1):
143
+ yc = y + 0.5
144
+ nodes: List[float] = []
145
+ j = n - 1
146
+ for i in range(n):
147
+ yi, yj = ys[i], ys[j]
148
+ if (yi < yc <= yj) or (yj < yc <= yi):
149
+ t = (yc - yi) / (yj - yi)
150
+ nodes.append(xs[i] + t * (xs[j] - xs[i]))
151
+ j = i
152
+ nodes.sort()
153
+ for a, b in zip(nodes[0::2], nodes[1::2]):
154
+ xa = max(0, int(np.ceil(a - 0.5)))
155
+ xb = min(width - 1, int(np.floor(b - 0.5)))
156
+ if xb >= xa:
157
+ mask[y, xa:xb + 1] = True
158
+ return mask
159
+
160
+
161
+ def _erode(mask: np.ndarray) -> np.ndarray:
162
+ """4 connected erosion (a pixel survives only if all N/S/E/W neighbours are set)."""
163
+ m = mask
164
+ out = m.copy()
165
+ out[1:, :] &= m[:-1, :]
166
+ out[:-1, :] &= m[1:, :]
167
+ out[:, 1:] &= m[:, :-1]
168
+ out[:, :-1] &= m[:, 1:]
169
+ # border pixels of a set region are boundary by definition
170
+ out[0, :] = False
171
+ out[-1, :] = False
172
+ out[:, 0] = False
173
+ out[:, -1] = False
174
+ return out & m
175
+
176
+
177
+ def _trace_boundary(comp: np.ndarray) -> np.ndarray:
178
+ """Moore neighbour boundary trace of a single connected component.
179
+
180
+ Returns an ordered (M, 2) array of (x, y) boundary pixels. Robust but simple:
181
+ if tracing fails to close, falls back to unordered boundary pixels.
182
+ """
183
+ ys, xs = np.nonzero(comp)
184
+ if len(xs) == 0:
185
+ return np.empty((0, 2), dtype=int)
186
+ # start pixel: topmost leftmost
187
+ start_idx = np.lexsort((xs, ys))[0]
188
+ start = (int(xs[start_idx]), int(ys[start_idx]))
189
+ h, w = comp.shape
190
+
191
+ def is_set(px, py):
192
+ return 0 <= px < w and 0 <= py < h and comp[py, px]
193
+
194
+ # 8 neighbour offsets clockwise starting from "west"
195
+ neigh = [(-1, 0), (-1, -1), (0, -1), (1, -1),
196
+ (1, 0), (1, 1), (0, 1), (-1, 1)]
197
+ boundary = [start]
198
+ current_pixel = start
199
+ backdir = 0
200
+ max_steps = comp.sum() * 8 + 16
201
+ for _ in range(int(max_steps)):
202
+ found = False
203
+ for k in range(8):
204
+ d = (backdir + k) % 8
205
+ nx, ny = current_pixel[0] + neigh[d][0], current_pixel[1] + neigh[d][1]
206
+ if is_set(nx, ny):
207
+ backdir = (d + 5) % 8 # step back and one over
208
+ current_pixel = (nx, ny)
209
+ if current_pixel == start and len(boundary) > 2:
210
+ return np.asarray(boundary, dtype=int)
211
+ boundary.append(current_pixel)
212
+ found = True
213
+ break
214
+ if not found:
215
+ break
216
+ # fallback: unordered boundary
217
+ eroded = _erode(comp)
218
+ bys, bxs = np.nonzero(comp & ~eroded)
219
+ return np.stack([bxs, bys], axis=1)
@@ -0,0 +1,127 @@
1
+ """Selection refinement operations.
2
+
3
+ * ``denoise_and_smooth`` — jaggy edge cleanup via morphological open/close,
4
+ iteration count knob only.
5
+ * ``soften`` — feathering, ``radius`` + ``iterations`` knobs, returns a *soft*
6
+ (float) mask, a distinct operation from denoise.
7
+ * ``form_quick_selections`` — threaded connected component split (delegates to
8
+ :mod:`connected_components`).
9
+ * ``separate_objects`` — explicit object separation, a distinct entry point
10
+ from quick select.
11
+ * ``set_forbidden_area`` / ``shrink_with_brush`` — prohibited area mask and
12
+ per sub selection erosion.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import List, Optional
18
+
19
+ import numpy as np
20
+
21
+ from .connected_components import split_components
22
+
23
+
24
+ def _binary_dilate(mask: np.ndarray, iterations: int = 1) -> np.ndarray:
25
+ m = mask.copy()
26
+ for _ in range(iterations):
27
+ out = m.copy()
28
+ out[1:, :] |= m[:-1, :]
29
+ out[:-1, :] |= m[1:, :]
30
+ out[:, 1:] |= m[:, :-1]
31
+ out[:, :-1] |= m[:, 1:]
32
+ m = out
33
+ return m
34
+
35
+
36
+ def _binary_erode(mask: np.ndarray, iterations: int = 1) -> np.ndarray:
37
+ m = mask.copy()
38
+ for _ in range(iterations):
39
+ out = m.copy()
40
+ out[1:, :] &= m[:-1, :]
41
+ out[:-1, :] &= m[1:, :]
42
+ out[:, 1:] &= m[:, :-1]
43
+ out[:, :-1] &= m[:, 1:]
44
+ # treat outside as empty, so borders erode away
45
+ out[0, :] &= m[0, :]
46
+ m = out
47
+ return m
48
+
49
+
50
+ def _box_blur(img: np.ndarray, radius: int) -> np.ndarray:
51
+ """Separable box blur via cumulative sums (float input/output)."""
52
+ if radius <= 0:
53
+ return img
54
+ pad = radius
55
+ win = 2 * radius + 1
56
+ padded = np.pad(img, pad, mode="edge")
57
+ # horizontal sliding window mean (returns width back to original W)
58
+ cs = np.cumsum(padded, axis=1)
59
+ cs = np.pad(cs, ((0, 0), (1, 0)), mode="constant")
60
+ padded = (cs[:, win:] - cs[:, :-win]) / win
61
+ # vertical sliding window mean (returns height back to original H)
62
+ cs = np.cumsum(padded, axis=0)
63
+ cs = np.pad(cs, ((1, 0), (0, 0)), mode="constant")
64
+ padded = (cs[win:, :] - cs[:-win, :]) / win
65
+ return padded
66
+
67
+
68
+ class SelectionEnhancer:
69
+ """Refinement operations over boolean/soft masks."""
70
+
71
+ def denoise_and_smooth(self, mask: np.ndarray, iterations: int = 1) -> np.ndarray:
72
+ """Morphological open then close — removes specks and fills pinholes.
73
+
74
+ Iteration count knob only, per PLAN (distinct from :meth:`soften`).
75
+ """
76
+ m = mask > 0
77
+ for _ in range(max(1, iterations)):
78
+ m = _binary_dilate(_binary_erode(m)) # open
79
+ m = _binary_erode(_binary_dilate(m)) # close
80
+ return m
81
+
82
+ def soften(self, mask: np.ndarray, radius: float = 3.0,
83
+ iterations: int = 1) -> np.ndarray:
84
+ """Feather the mask edge; returns a soft float mask in [0, 1].
85
+
86
+ ``radius`` + ``iterations`` knobs, per PLAN — a different operation from
87
+ denoise. Repeated box blur approximates a Gaussian feather.
88
+ """
89
+ soft = (mask > 0).astype(np.float32)
90
+ r = max(1, int(round(radius)))
91
+ for _ in range(max(1, iterations)):
92
+ soft = _box_blur(soft, r).astype(np.float32)
93
+ return np.clip(soft, 0.0, 1.0)
94
+
95
+ def shrink_with_brush(self, mask: np.ndarray, radius: int = 1) -> np.ndarray:
96
+ """Erode the mask by a brush radius."""
97
+ return _binary_erode(mask > 0, iterations=max(1, radius))
98
+
99
+ def form_quick_selections(self, mask: np.ndarray, max_selections: int = 0,
100
+ min_area: int = 1, workers: int = 4) -> List[np.ndarray]:
101
+ """Split into sub selections across a bounded worker pool."""
102
+ return split_components(mask, max_selections=max_selections,
103
+ min_area=min_area, workers=workers)
104
+
105
+ def separate_objects(self, mask: np.ndarray, min_area: int = 1) -> List[np.ndarray]:
106
+ """Explicit object separation, a distinct entry point from quick select.
107
+
108
+ Separates touching but thin
109
+ connections with an erosion before labelling, then dilates each object
110
+ back to its original extent (a watershed lite).
111
+ """
112
+ core = _binary_erode(mask > 0, iterations=1)
113
+ comps = split_components(core, min_area=min_area)
114
+ result = []
115
+ for c in comps:
116
+ grown = _binary_dilate(c, iterations=1) & (mask > 0)
117
+ result.append(grown)
118
+ return result
119
+
120
+ def set_forbidden_area(self, mask: np.ndarray,
121
+ forbidden: np.ndarray) -> np.ndarray:
122
+ """Remove the forbidden/prohibited area from a selection.
123
+
124
+ The prohibited mask marks regions that must never be edited or used as a
125
+ fill source.
126
+ """
127
+ return (mask > 0) & ~(forbidden > 0)
@@ -0,0 +1,7 @@
1
+ """Pipeline orchestration."""
2
+
3
+ from .remove_object_job import (
4
+ RemoveObjectJob, EngineTier, JobResult, ProgressFn, CancelFn,
5
+ )
6
+
7
+ __all__ = ["RemoveObjectJob", "EngineTier", "JobResult", "ProgressFn", "CancelFn"]