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,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)
|