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,152 @@
|
|
|
1
|
+
"""PatchMatch inpainting solver, one scale level.
|
|
2
|
+
|
|
3
|
+
Classic Barnes et al. PatchMatch (random init → propagation → random search)
|
|
4
|
+
wrapped in an EM loop: search the nearest neighbour field, then reconstruct the
|
|
5
|
+
hole by patch voting, repeat. All work is done in a *padded* coordinate frame so
|
|
6
|
+
every patch window is fully in bounds and distance is a fixed size slice.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .patch_scorer import PatchScorer
|
|
16
|
+
from .patch_search_index import PatchSearchIndex
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class PatchMatchSolver:
|
|
21
|
+
radius: int = 3
|
|
22
|
+
pm_iters: int = 4 # propagation + random search sweeps per EM step
|
|
23
|
+
em_iters: int = 4 # search/reconstruct alternations
|
|
24
|
+
seed: int = 1234
|
|
25
|
+
|
|
26
|
+
def __post_init__(self):
|
|
27
|
+
self.scorer = PatchScorer(self.radius)
|
|
28
|
+
|
|
29
|
+
def solve_level(self, feat: np.ndarray, hole: np.ndarray,
|
|
30
|
+
invalid: np.ndarray, progress=None, cancel=None) -> np.ndarray:
|
|
31
|
+
"""Fill ``hole`` in the padded image ``feat``.
|
|
32
|
+
|
|
33
|
+
``invalid`` marks pixels that must not be sourced from (all holes).
|
|
34
|
+
Returns a new padded image with hole pixels reconstructed.
|
|
35
|
+
"""
|
|
36
|
+
r = self.radius
|
|
37
|
+
rng = np.random.default_rng(self.seed)
|
|
38
|
+
Hp, Wp = hole.shape
|
|
39
|
+
index = PatchSearchIndex(invalid, r)
|
|
40
|
+
if index.n_sources == 0:
|
|
41
|
+
return feat.copy()
|
|
42
|
+
|
|
43
|
+
targets = self._target_centers(hole, r)
|
|
44
|
+
nnf_c, nnf_d = self._init_nnf(feat, targets, index, rng)
|
|
45
|
+
|
|
46
|
+
current_estimate = feat.copy()
|
|
47
|
+
for em in range(self.em_iters):
|
|
48
|
+
if cancel is not None and cancel():
|
|
49
|
+
break
|
|
50
|
+
self._refresh(current_estimate, nnf_c, nnf_d, targets)
|
|
51
|
+
for it in range(self.pm_iters):
|
|
52
|
+
self._sweep(current_estimate, nnf_c, nnf_d, targets, index, rng,
|
|
53
|
+
reverse=(it % 2 == 1))
|
|
54
|
+
current_estimate = self._vote(current_estimate, hole, nnf_c, nnf_d, targets)
|
|
55
|
+
if progress is not None:
|
|
56
|
+
progress((em + 1) / self.em_iters)
|
|
57
|
+
return current_estimate
|
|
58
|
+
|
|
59
|
+
def _target_centers(self, hole: np.ndarray, r: int) -> np.ndarray:
|
|
60
|
+
"""Centers whose patch overlaps the hole (hole dilated by r), full window in bounds."""
|
|
61
|
+
Hp, Wp = hole.shape
|
|
62
|
+
dil = hole.copy()
|
|
63
|
+
for _ in range(r):
|
|
64
|
+
nxt = dil.copy()
|
|
65
|
+
nxt[1:, :] |= dil[:-1, :]
|
|
66
|
+
nxt[:-1, :] |= dil[1:, :]
|
|
67
|
+
nxt[:, 1:] |= dil[:, :-1]
|
|
68
|
+
nxt[:, :-1] |= dil[:, 1:]
|
|
69
|
+
dil = nxt
|
|
70
|
+
dil[:r, :] = False
|
|
71
|
+
dil[Hp - r:, :] = False
|
|
72
|
+
dil[:, :r] = False
|
|
73
|
+
dil[:, Wp - r:] = False
|
|
74
|
+
ys, xs = np.nonzero(dil)
|
|
75
|
+
return np.stack([ys, xs], axis=1).astype(np.int32)
|
|
76
|
+
|
|
77
|
+
def _init_nnf(self, feat, targets, index, rng):
|
|
78
|
+
Hp, Wp = feat.shape[:2]
|
|
79
|
+
coords = np.zeros((Hp, Wp, 2), dtype=np.int32)
|
|
80
|
+
coords[:, :, 0] = np.arange(Hp)[:, None]
|
|
81
|
+
coords[:, :, 1] = np.arange(Wp)[None, :]
|
|
82
|
+
dist = np.full((Hp, Wp), np.inf)
|
|
83
|
+
# known pixels with a fully valid window are zero cost identity seeds
|
|
84
|
+
dist[index.source_ok] = 0.0
|
|
85
|
+
samples = index.sample(rng, len(targets))
|
|
86
|
+
for i, (y, x) in enumerate(targets):
|
|
87
|
+
sy, sx = int(samples[i, 0]), int(samples[i, 1])
|
|
88
|
+
coords[y, x] = (sy, sx)
|
|
89
|
+
dist[y, x] = self.scorer.distance(feat, y, x, sy, sx)
|
|
90
|
+
return coords, dist
|
|
91
|
+
|
|
92
|
+
def _refresh(self, feat, coords, dist, targets):
|
|
93
|
+
for (y, x) in targets:
|
|
94
|
+
sy, sx = int(coords[y, x, 0]), int(coords[y, x, 1])
|
|
95
|
+
dist[y, x] = self.scorer.distance(feat, y, x, sy, sx)
|
|
96
|
+
|
|
97
|
+
def _sweep(self, feat, coords, dist, targets, index, rng, reverse):
|
|
98
|
+
order = targets[::-1] if reverse else targets
|
|
99
|
+
delta = -1 if reverse else 1
|
|
100
|
+
r = self.radius
|
|
101
|
+
Hp, Wp = feat.shape[:2]
|
|
102
|
+
for (y, x) in order:
|
|
103
|
+
best_d = dist[y, x]
|
|
104
|
+
best = (int(coords[y, x, 0]), int(coords[y, x, 1]))
|
|
105
|
+
# propagation from two causal neighbours
|
|
106
|
+
for (ny, nx) in ((y - delta, x), (y, x - delta)):
|
|
107
|
+
if 0 <= ny < Hp and 0 <= nx < Wp and np.isfinite(dist[ny, nx]):
|
|
108
|
+
cand = (int(coords[ny, nx, 0]) + (y - ny),
|
|
109
|
+
int(coords[ny, nx, 1]) + (x - nx))
|
|
110
|
+
if index.is_source_ok(*cand):
|
|
111
|
+
d = self.scorer.distance(feat, y, x, cand[0], cand[1])
|
|
112
|
+
if d < best_d:
|
|
113
|
+
best_d, best = d, cand
|
|
114
|
+
# random search, exponentially shrinking window
|
|
115
|
+
w = max(Hp, Wp)
|
|
116
|
+
while w >= 1:
|
|
117
|
+
cy = best[0] + int(rng.integers(-w, w + 1))
|
|
118
|
+
cx = best[1] + int(rng.integers(-w, w + 1))
|
|
119
|
+
if index.is_source_ok(cy, cx):
|
|
120
|
+
d = self.scorer.distance(feat, y, x, cy, cx)
|
|
121
|
+
if d < best_d:
|
|
122
|
+
best_d, best = d, (cy, cx)
|
|
123
|
+
w //= 2
|
|
124
|
+
coords[y, x] = best
|
|
125
|
+
dist[y, x] = best_d
|
|
126
|
+
|
|
127
|
+
def _vote(self, feat, hole, coords, dist, targets):
|
|
128
|
+
r = self.radius
|
|
129
|
+
Hp, Wp, C = feat.shape
|
|
130
|
+
acc = np.zeros((Hp, Wp, C), dtype=np.float64)
|
|
131
|
+
wsum = np.zeros((Hp, Wp, 1), dtype=np.float64)
|
|
132
|
+
# Weight scale from the *nonzero* target distances only. Known pixel
|
|
133
|
+
# identity seeds sit at distance 0 and would otherwise pin the median to
|
|
134
|
+
# 0, collapsing every real match's weight to ~0 and freezing the fill.
|
|
135
|
+
target_dists = dist[targets[:, 0], targets[:, 1]]
|
|
136
|
+
nonzero_target_dists = target_dists[np.isfinite(target_dists) & (target_dists > 1e-9)]
|
|
137
|
+
weight_variance = (2.0 * (np.median(nonzero_target_dists) + 1e-6)
|
|
138
|
+
if nonzero_target_dists.size else 1.0)
|
|
139
|
+
for (y, x) in targets:
|
|
140
|
+
d = dist[y, x]
|
|
141
|
+
if not np.isfinite(d):
|
|
142
|
+
continue
|
|
143
|
+
sy, sx = int(coords[y, x, 0]), int(coords[y, x, 1])
|
|
144
|
+
w = np.exp(-d / (weight_variance + 1e-9))
|
|
145
|
+
src = feat[sy - r:sy + r + 1, sx - r:sx + r + 1]
|
|
146
|
+
acc[y - r:y + r + 1, x - r:x + r + 1] += w * src
|
|
147
|
+
wsum[y - r:y + r + 1, x - r:x + r + 1] += w
|
|
148
|
+
out = feat.copy()
|
|
149
|
+
filled = (wsum[:, :, 0] > 0) & hole
|
|
150
|
+
recon = acc / np.maximum(wsum, 1e-9)
|
|
151
|
+
out[filled] = recon[filled]
|
|
152
|
+
return out
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""Tier 2 — self similar rigid shift + seam carve.
|
|
2
|
+
|
|
3
|
+
Pipeline:
|
|
4
|
+
1. ORB = FAST corners + 256 bit BRIEF descriptors + Hamming matching.
|
|
5
|
+
2. ``find_far_and_similar_points`` — a keypoint pair that is visually similar
|
|
6
|
+
but spatially far gives a candidate *rigid translation* (dx, dy) that copies
|
|
7
|
+
a self similar chunk of the image over the hole. Run twice (coarse/refine).
|
|
8
|
+
3. Score candidate offsets by border band SSD (continuity across the hole edge)
|
|
9
|
+
with a validity constraint, then copy the shifted block over the hole.
|
|
10
|
+
4. A dynamic programming seam carve (``compute_path``) cuts the copied block
|
|
11
|
+
in cleanly, computed once for x, then again for y by *transposing the
|
|
12
|
+
image and transposing back*, so the same 1D seam routine handles both axes.
|
|
13
|
+
|
|
14
|
+
This is the cheap, good enough default path for homogeneous/repetitive texture
|
|
15
|
+
(grass, sky, brick, wood). The final feather/blend is left to Stage E.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from ..imageutil import as_float_rgb, rgb_to_gray
|
|
26
|
+
from ..pyramid.image_pyramid import gaussian_blur
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ==========================================================================
|
|
30
|
+
# ORB: FAST corners + BRIEF descriptors + Hamming matching
|
|
31
|
+
# ==========================================================================
|
|
32
|
+
_FAST_CIRCLE = [(0, -3), (1, -3), (2, -2), (3, -1), (3, 0), (3, 1), (2, 2),
|
|
33
|
+
(1, 3), (0, 3), (-1, 3), (-2, 2), (-3, 1), (-3, 0), (-3, -1),
|
|
34
|
+
(-2, -2), (-1, -3)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BriefKeyPoint:
|
|
39
|
+
y: int
|
|
40
|
+
x: int
|
|
41
|
+
descriptor: np.ndarray # (32,) uint8 packed = 256 bits
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fast_corners(gray: np.ndarray, valid: np.ndarray, threshold: float = 0.06,
|
|
45
|
+
n_contig: int = 9, border: int = 16) -> List[Tuple[int, int]]:
|
|
46
|
+
"""FAST 9 corner detector over valid pixels only."""
|
|
47
|
+
H, W = gray.shape
|
|
48
|
+
pts: List[Tuple[int, int]] = []
|
|
49
|
+
circ = _FAST_CIRCLE
|
|
50
|
+
for y in range(border, H - border):
|
|
51
|
+
for x in range(border, W - border):
|
|
52
|
+
if not valid[y, x]:
|
|
53
|
+
continue
|
|
54
|
+
center_val = gray[y, x]
|
|
55
|
+
hi = center_val + threshold
|
|
56
|
+
lo = center_val - threshold
|
|
57
|
+
# quick reject on the 4 compass points
|
|
58
|
+
reject_count = 0
|
|
59
|
+
for dx, dy in (circ[0], circ[4], circ[8], circ[12]):
|
|
60
|
+
v = gray[y + dy, x + dx]
|
|
61
|
+
if v > hi or v < lo:
|
|
62
|
+
reject_count += 1
|
|
63
|
+
if reject_count < 3:
|
|
64
|
+
continue
|
|
65
|
+
states = []
|
|
66
|
+
for dx, dy in circ:
|
|
67
|
+
v = gray[y + dy, x + dx]
|
|
68
|
+
states.append(1 if v > hi else (-1 if v < lo else 0))
|
|
69
|
+
if _has_contiguous(states, n_contig):
|
|
70
|
+
pts.append((y, x))
|
|
71
|
+
return pts
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _has_contiguous(states: List[int], n: int) -> bool:
|
|
75
|
+
ext = states + states[:n - 1]
|
|
76
|
+
for sgn in (1, -1):
|
|
77
|
+
run = 0
|
|
78
|
+
for s in ext:
|
|
79
|
+
if s == sgn:
|
|
80
|
+
run += 1
|
|
81
|
+
if run >= n:
|
|
82
|
+
return True
|
|
83
|
+
else:
|
|
84
|
+
run = 0
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _brief_pattern(patch: int = 15, n_bits: int = 256, seed: int = 42) -> np.ndarray:
|
|
89
|
+
rng = np.random.default_rng(seed)
|
|
90
|
+
return rng.integers(-patch, patch + 1, size=(n_bits, 4)).astype(np.int32)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_BRIEF = _brief_pattern()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def brief_descriptors(gray: np.ndarray, pts: List[Tuple[int, int]]) -> List[BriefKeyPoint]:
|
|
97
|
+
"""256 bit BRIEF on a smoothed image, packed into 32 uint8 bytes."""
|
|
98
|
+
sm = gaussian_blur(gray)
|
|
99
|
+
H, W = sm.shape
|
|
100
|
+
out: List[BriefKeyPoint] = []
|
|
101
|
+
pat = _BRIEF
|
|
102
|
+
for (y, x) in pts:
|
|
103
|
+
bits = np.zeros(256, dtype=np.uint8)
|
|
104
|
+
for i in range(256):
|
|
105
|
+
ay = np.clip(y + pat[i, 1], 0, H - 1)
|
|
106
|
+
ax = np.clip(x + pat[i, 0], 0, W - 1)
|
|
107
|
+
by = np.clip(y + pat[i, 3], 0, H - 1)
|
|
108
|
+
bx = np.clip(x + pat[i, 2], 0, W - 1)
|
|
109
|
+
bits[i] = 1 if sm[ay, ax] < sm[by, bx] else 0
|
|
110
|
+
packed = np.packbits(bits)
|
|
111
|
+
out.append(BriefKeyPoint(y, x, packed))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def hamming(a: np.ndarray, b: np.ndarray) -> np.ndarray:
|
|
116
|
+
"""Hamming distance between packed descriptor ``a`` (32,) and rows of ``b`` (N,32)."""
|
|
117
|
+
return np.bitwise_count(np.bitwise_xor(b, a[None, :])).sum(axis=1)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ==========================================================================
|
|
121
|
+
# offset proposal
|
|
122
|
+
# ==========================================================================
|
|
123
|
+
def find_far_and_similar_points(kps: List[BriefKeyPoint], hole_bbox,
|
|
124
|
+
min_offset: int, max_pairs: int = 40
|
|
125
|
+
) -> List[Tuple[int, int]]:
|
|
126
|
+
"""Propose rigid (oy, ox) offsets from visually similar, spatially far pairs.
|
|
127
|
+
|
|
128
|
+
For keypoints near the hole, find the descriptor nearest keypoint that lies
|
|
129
|
+
at least ``min_offset`` away; the vector between them is a candidate shift.
|
|
130
|
+
"""
|
|
131
|
+
if len(kps) < 2:
|
|
132
|
+
return []
|
|
133
|
+
y0, x0, y1, x1 = hole_bbox
|
|
134
|
+
cy, cx = (y0 + y1) / 2, (x0 + x1) / 2
|
|
135
|
+
db = np.stack([k.descriptor for k in kps])
|
|
136
|
+
coords = np.array([(k.y, k.x) for k in kps])
|
|
137
|
+
# anchors: keypoints closest to the hole (best boundary continuity)
|
|
138
|
+
d_to_hole = np.hypot(coords[:, 0] - cy, coords[:, 1] - cx)
|
|
139
|
+
anchor_idx = np.argsort(d_to_hole)[:max_pairs]
|
|
140
|
+
offsets: List[Tuple[int, int]] = []
|
|
141
|
+
for ai in anchor_idx:
|
|
142
|
+
dists = hamming(db[ai], db)
|
|
143
|
+
far = np.hypot(coords[:, 0] - coords[ai, 0],
|
|
144
|
+
coords[:, 1] - coords[ai, 1]) >= min_offset
|
|
145
|
+
cand = np.where(far)[0]
|
|
146
|
+
if cand.size == 0:
|
|
147
|
+
continue
|
|
148
|
+
best = cand[np.argmin(dists[cand])]
|
|
149
|
+
oy = int(coords[best, 0] - coords[ai, 0])
|
|
150
|
+
ox = int(coords[best, 1] - coords[ai, 1])
|
|
151
|
+
offsets.append((oy, ox))
|
|
152
|
+
return offsets
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ==========================================================================
|
|
156
|
+
# DP seam carving (computePath) + transpose trick
|
|
157
|
+
# ==========================================================================
|
|
158
|
+
def compute_path(energy: np.ndarray) -> np.ndarray:
|
|
159
|
+
"""Min cost vertical seam via DP. Returns one column index per row."""
|
|
160
|
+
h, w = energy.shape
|
|
161
|
+
cost = energy.astype(np.float64).copy()
|
|
162
|
+
back = np.zeros((h, w), dtype=np.int32)
|
|
163
|
+
for y in range(1, h):
|
|
164
|
+
for x in range(w):
|
|
165
|
+
xl = max(0, x - 1)
|
|
166
|
+
xr = min(w - 1, x + 1)
|
|
167
|
+
choices = cost[y - 1, xl:xr + 1]
|
|
168
|
+
k = int(np.argmin(choices))
|
|
169
|
+
back[y, x] = xl + k
|
|
170
|
+
cost[y, x] += choices[k]
|
|
171
|
+
seam = np.zeros(h, dtype=np.int32)
|
|
172
|
+
seam[-1] = int(np.argmin(cost[-1]))
|
|
173
|
+
for y in range(h - 2, -1, -1):
|
|
174
|
+
seam[y] = back[y + 1, seam[y + 1]]
|
|
175
|
+
return seam
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def transpose_texture(img: np.ndarray) -> np.ndarray:
|
|
179
|
+
if img.ndim == 3:
|
|
180
|
+
return np.transpose(img, (1, 0, 2))
|
|
181
|
+
return img.T
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ==========================================================================
|
|
185
|
+
# engine
|
|
186
|
+
# ==========================================================================
|
|
187
|
+
class SelfSimilarShiftEngine:
|
|
188
|
+
def __init__(self, margin: int = 8, fast_threshold: float = 0.06,
|
|
189
|
+
brute_step: int = 6):
|
|
190
|
+
self.margin = margin
|
|
191
|
+
self.fast_threshold = fast_threshold
|
|
192
|
+
self.brute_step = brute_step
|
|
193
|
+
# border continuity SSD of the chosen offset; low => good self similarity.
|
|
194
|
+
# Read by the pipeline's AUTO tier gate. inf means no valid offset found.
|
|
195
|
+
self.last_border_score: float = float("inf")
|
|
196
|
+
|
|
197
|
+
def fill(self, image: np.ndarray, mask: np.ndarray,
|
|
198
|
+
progress=None, cancel=None) -> np.ndarray:
|
|
199
|
+
img = as_float_rgb(image).astype(np.float64)
|
|
200
|
+
hole = np.ascontiguousarray(mask > 0)
|
|
201
|
+
H, W = hole.shape
|
|
202
|
+
if not hole.any():
|
|
203
|
+
return img.copy()
|
|
204
|
+
|
|
205
|
+
ys, xs = np.nonzero(hole)
|
|
206
|
+
bbox = (int(ys.min()), int(xs.min()), int(ys.max()) + 1, int(xs.max()) + 1)
|
|
207
|
+
hole_span = max(bbox[3] - bbox[1], bbox[2] - bbox[0])
|
|
208
|
+
|
|
209
|
+
gray = rgb_to_gray(img.astype(np.float32))
|
|
210
|
+
valid = ~hole
|
|
211
|
+
if progress:
|
|
212
|
+
progress(0.1)
|
|
213
|
+
|
|
214
|
+
# ORB: corners + descriptors
|
|
215
|
+
corners = fast_corners(gray, valid, self.fast_threshold, border=16)
|
|
216
|
+
kps = brief_descriptors(gray, corners)
|
|
217
|
+
if progress:
|
|
218
|
+
progress(0.4)
|
|
219
|
+
|
|
220
|
+
# candidate offsets: coarse then refine pass
|
|
221
|
+
candidates: List[Tuple[int, int]] = []
|
|
222
|
+
candidates += find_far_and_similar_points(kps, bbox, min_offset=int(1.5 * hole_span))
|
|
223
|
+
candidates += find_far_and_similar_points(kps, bbox, min_offset=max(4, hole_span // 2))
|
|
224
|
+
candidates += self._brute_offsets(hole_span, H, W)
|
|
225
|
+
candidates = list({(int(o[0]), int(o[1])) for o in candidates if o != (0, 0)})
|
|
226
|
+
|
|
227
|
+
best, best_score = self._pick_offset(img, hole, bbox, candidates)
|
|
228
|
+
self.last_border_score = best_score
|
|
229
|
+
if best is None:
|
|
230
|
+
return img.copy() # nothing valid; caller may fall back to another tier
|
|
231
|
+
if progress:
|
|
232
|
+
progress(0.7)
|
|
233
|
+
|
|
234
|
+
filled = self._composite_with_seam(img, hole, bbox, best)
|
|
235
|
+
if progress:
|
|
236
|
+
progress(1.0)
|
|
237
|
+
return filled
|
|
238
|
+
|
|
239
|
+
def _brute_offsets(self, hole_span, H, W) -> List[Tuple[int, int]]:
|
|
240
|
+
"""A coarse grid of translations as a fallback when ORB is sparse."""
|
|
241
|
+
step = max(self.brute_step, hole_span // 2)
|
|
242
|
+
offs = []
|
|
243
|
+
rng = range(-max(hole_span + self.margin, 2 * step),
|
|
244
|
+
max(hole_span + self.margin, 2 * step) + 1, step)
|
|
245
|
+
for oy in rng:
|
|
246
|
+
for ox in rng:
|
|
247
|
+
if abs(oy) + abs(ox) >= hole_span:
|
|
248
|
+
offs.append((oy, ox))
|
|
249
|
+
return offs
|
|
250
|
+
|
|
251
|
+
def _pick_offset(self, img, hole, bbox, candidates):
|
|
252
|
+
H, W = hole.shape
|
|
253
|
+
y0, x0, y1, x1 = bbox
|
|
254
|
+
by0, bx0 = max(0, y0 - self.margin), max(0, x0 - self.margin)
|
|
255
|
+
by1, bx1 = min(H, y1 + self.margin), min(W, x1 + self.margin)
|
|
256
|
+
band = np.zeros((H, W), dtype=bool)
|
|
257
|
+
band[by0:by1, bx0:bx1] = True
|
|
258
|
+
band &= ~hole # known context ring around the hole
|
|
259
|
+
band_idx = np.nonzero(band)
|
|
260
|
+
bys, bxs = band_idx
|
|
261
|
+
best_score = np.inf
|
|
262
|
+
best = None
|
|
263
|
+
for (oy, ox) in candidates:
|
|
264
|
+
sy = bys - oy
|
|
265
|
+
sx = bxs - ox
|
|
266
|
+
ok = (sy >= 0) & (sy < H) & (sx >= 0) & (sx < W)
|
|
267
|
+
if ok.mean() < 0.9:
|
|
268
|
+
continue
|
|
269
|
+
syo, sxo, byo, bxo = sy[ok], sx[ok], bys[ok], bxs[ok]
|
|
270
|
+
src_valid = ~hole[syo, sxo]
|
|
271
|
+
if src_valid.mean() < 0.9:
|
|
272
|
+
continue
|
|
273
|
+
d = img[byo, bxo] - img[syo, sxo]
|
|
274
|
+
score = float((d * d).sum(axis=1)[src_valid].mean())
|
|
275
|
+
if not self._hole_source_valid(hole, bbox, oy, ox):
|
|
276
|
+
continue
|
|
277
|
+
if score < best_score:
|
|
278
|
+
best_score = score
|
|
279
|
+
best = (oy, ox)
|
|
280
|
+
return best, best_score
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _hole_source_valid(hole, bbox, oy, ox, min_frac=0.97):
|
|
284
|
+
H, W = hole.shape
|
|
285
|
+
ys, xs = np.nonzero(hole)
|
|
286
|
+
sy, sx = ys - oy, xs - ox
|
|
287
|
+
ok = (sy >= 0) & (sy < H) & (sx >= 0) & (sx < W)
|
|
288
|
+
if ok.mean() < min_frac:
|
|
289
|
+
return False
|
|
290
|
+
src_hole = np.ones(len(ys), dtype=bool)
|
|
291
|
+
src_hole[ok] = hole[sy[ok], sx[ok]]
|
|
292
|
+
return (~src_hole).mean() >= min_frac
|
|
293
|
+
|
|
294
|
+
def _composite_with_seam(self, img, hole, bbox, offset):
|
|
295
|
+
H, W = hole.shape
|
|
296
|
+
oy, ox = offset
|
|
297
|
+
out = img.copy()
|
|
298
|
+
copied = self._shift(img, oy, ox)
|
|
299
|
+
|
|
300
|
+
y0, x0, y1, x1 = bbox
|
|
301
|
+
ry0, rx0 = max(0, y0 - self.margin), max(0, x0 - self.margin)
|
|
302
|
+
ry1, rx1 = min(H, y1 + self.margin), min(W, x1 + self.margin)
|
|
303
|
+
|
|
304
|
+
# per pixel energy = colour difference between original and copied
|
|
305
|
+
diff = ((img - copied) ** 2).sum(axis=2)
|
|
306
|
+
|
|
307
|
+
use_copied = hole.copy()
|
|
308
|
+
region = np.zeros((H, W), dtype=bool)
|
|
309
|
+
region[ry0:ry1, rx0:rx1] = True
|
|
310
|
+
|
|
311
|
+
# x direction seams: trim left & right margins
|
|
312
|
+
use_copied |= self._side_seam(diff, hole, region, axis="left")
|
|
313
|
+
use_copied |= self._side_seam(diff, hole, region, axis="right")
|
|
314
|
+
# y direction via transpose
|
|
315
|
+
diff_t = transpose_texture(diff)
|
|
316
|
+
hole_t = transpose_texture(hole)
|
|
317
|
+
region_t = transpose_texture(region)
|
|
318
|
+
top = self._side_seam(diff_t, hole_t, region_t, axis="left")
|
|
319
|
+
bot = self._side_seam(diff_t, hole_t, region_t, axis="right")
|
|
320
|
+
use_copied |= transpose_texture(top)
|
|
321
|
+
use_copied |= transpose_texture(bot)
|
|
322
|
+
|
|
323
|
+
use_copied &= region # never touch pixels outside the working region
|
|
324
|
+
use_copied |= hole
|
|
325
|
+
out[use_copied] = copied[use_copied]
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
@staticmethod
|
|
329
|
+
def _shift(img, oy, ox):
|
|
330
|
+
"""Shift image so that out[y,x] = img[y-oy, x-ox] (edge clamped)."""
|
|
331
|
+
H, W = img.shape[:2]
|
|
332
|
+
ys = np.clip(np.arange(H) - oy, 0, H - 1)
|
|
333
|
+
xs = np.clip(np.arange(W) - ox, 0, W - 1)
|
|
334
|
+
return img[np.ix_(ys, xs)]
|
|
335
|
+
|
|
336
|
+
def _side_seam(self, diff, hole, region, axis: str) -> np.ndarray:
|
|
337
|
+
"""Find a min energy vertical seam in the left/right margin strip.
|
|
338
|
+
|
|
339
|
+
Returns a mask of pixels that should take the copied block, bounded by
|
|
340
|
+
the seam (between the hole edge and the region edge).
|
|
341
|
+
"""
|
|
342
|
+
H, W = hole.shape
|
|
343
|
+
rys, rxs = np.nonzero(region)
|
|
344
|
+
if rys.size == 0:
|
|
345
|
+
return np.zeros((H, W), dtype=bool)
|
|
346
|
+
ry0, ry1 = rys.min(), rys.max() + 1
|
|
347
|
+
rx0, rx1 = rxs.min(), rxs.max() + 1
|
|
348
|
+
out = np.zeros((H, W), dtype=bool)
|
|
349
|
+
for y in range(ry0, ry1):
|
|
350
|
+
hx = np.nonzero(hole[y])[0]
|
|
351
|
+
if hx.size == 0:
|
|
352
|
+
continue
|
|
353
|
+
if axis == "left":
|
|
354
|
+
strip_x0, strip_x1 = rx0, hx.min()
|
|
355
|
+
else:
|
|
356
|
+
strip_x0, strip_x1 = hx.max() + 1, rx1
|
|
357
|
+
if strip_x1 <= strip_x0:
|
|
358
|
+
continue
|
|
359
|
+
strip = diff[ry0:ry1, strip_x0:strip_x1]
|
|
360
|
+
# cheap per row seam via cumulative min DP done once over the strip
|
|
361
|
+
seam = compute_path(strip)
|
|
362
|
+
yy = y - ry0
|
|
363
|
+
cut = strip_x0 + seam[yy]
|
|
364
|
+
if axis == "left":
|
|
365
|
+
out[y, cut:strip_x1] = True
|
|
366
|
+
else:
|
|
367
|
+
out[y, strip_x0:cut + 1] = True
|
|
368
|
+
return out
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Small shared image helpers: I/O and type conversions.
|
|
2
|
+
|
|
3
|
+
Images inside the pipeline are ``float32`` arrays in ``[0, 1]`` with shape
|
|
4
|
+
``(H, W, 3)`` for RGB. Masks are ``uint8`` / ``bool`` arrays of shape ``(H, W)``
|
|
5
|
+
where nonzero means "inside the hole to be filled".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_image(path: str) -> np.ndarray:
|
|
14
|
+
"""Load an image as float32 RGB in [0, 1]."""
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
img = Image.open(path).convert("RGB")
|
|
18
|
+
return np.asarray(img, dtype=np.float32) / 255.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def save_image(path: str, image: np.ndarray) -> None:
|
|
22
|
+
"""Save a float32 RGB [0, 1] image."""
|
|
23
|
+
from PIL import Image
|
|
24
|
+
|
|
25
|
+
arr = to_uint8(image)
|
|
26
|
+
Image.fromarray(arr, mode="RGB").save(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_uint8(image: np.ndarray) -> np.ndarray:
|
|
30
|
+
return np.clip(image * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def as_float_rgb(image: np.ndarray) -> np.ndarray:
|
|
34
|
+
"""Normalise an arbitrary input array to float32 RGB in [0, 1]."""
|
|
35
|
+
arr = np.asarray(image)
|
|
36
|
+
if arr.dtype == np.uint8:
|
|
37
|
+
arr = arr.astype(np.float32) / 255.0
|
|
38
|
+
else:
|
|
39
|
+
arr = arr.astype(np.float32)
|
|
40
|
+
if arr.ndim == 2:
|
|
41
|
+
arr = np.repeat(arr[:, :, None], 3, axis=2)
|
|
42
|
+
if arr.shape[2] == 4:
|
|
43
|
+
arr = arr[:, :, :3]
|
|
44
|
+
return np.ascontiguousarray(arr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rgb_to_gray(image: np.ndarray) -> np.ndarray:
|
|
48
|
+
"""ITU-R 601-2 luma. Input float RGB, output float (H, W)."""
|
|
49
|
+
w = np.array([0.299, 0.587, 0.114], dtype=np.float32)
|
|
50
|
+
return image.reshape(-1, 3).dot(w).reshape(image.shape[:2])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clamp_rect(x: int, y: int, rect_w: int, rect_h: int, width: int, height: int):
|
|
54
|
+
"""Clamp a rectangle to image bounds; return (x0, y0, x1, y1) exclusive."""
|
|
55
|
+
x0 = max(0, x)
|
|
56
|
+
y0 = max(0, y)
|
|
57
|
+
x1 = min(width, x + rect_w)
|
|
58
|
+
y1 = min(height, y + rect_h)
|
|
59
|
+
return x0, y0, x1, y1
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Stage A — mask acquisition & refinement.
|
|
2
|
+
|
|
3
|
+
Selections are polygon/scanline based, with a raster mask as a *cached
|
|
4
|
+
derivative*: :class:`PolygonSelection` is the primary representation (exact,
|
|
5
|
+
resolution independent boolean ops), and a raster mask is generated on demand
|
|
6
|
+
for consumption by the fill/render stages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .polygon_selection import PolygonSelection
|
|
10
|
+
from .brush_rasterizer import BrushRasterizer
|
|
11
|
+
from .selection_enhancer import SelectionEnhancer
|
|
12
|
+
from .connected_components import label_components, split_components
|
|
13
|
+
from .auto_select import AutoSelectModel
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PolygonSelection",
|
|
17
|
+
"BrushRasterizer",
|
|
18
|
+
"SelectionEnhancer",
|
|
19
|
+
"label_components",
|
|
20
|
+
"split_components",
|
|
21
|
+
"AutoSelectModel",
|
|
22
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""AI assisted auto select — the "tap to select" front end.
|
|
2
|
+
|
|
3
|
+
A real deployment would back this with a quantised segmentation model (a
|
|
4
|
+
generic one, or a person specific variant for portrait selection). This
|
|
5
|
+
module defines the clean interface such a model would plug into and provides
|
|
6
|
+
a **working, dependency free fallback**: a tap seeded flood fill over color
|
|
7
|
+
similarity, so tap to select runs end to end without shipping any model
|
|
8
|
+
weights.
|
|
9
|
+
|
|
10
|
+
To wire a real model, subclass :class:`AutoSelectModel` and override
|
|
11
|
+
:meth:`segment`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections import deque
|
|
17
|
+
from typing import Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from ..imageutil import as_float_rgb, rgb_to_gray
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AutoSelectModel:
|
|
25
|
+
"""Interface for tap/box to segmentation mask.
|
|
26
|
+
|
|
27
|
+
``backend`` is informational; the base class runs the color flood fallback.
|
|
28
|
+
A real deployment would pass ``backend="deeplabv3_int8"`` (generic) or
|
|
29
|
+
``backend="human_seg"`` (the person specific variant) and load quantised
|
|
30
|
+
weights in an override.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, backend: str = "color_flood_fallback"):
|
|
34
|
+
self.backend = backend
|
|
35
|
+
|
|
36
|
+
def segment(self, image: np.ndarray, seed: Tuple[int, int],
|
|
37
|
+
tolerance: float = 0.10, max_area_frac: float = 0.6) -> np.ndarray:
|
|
38
|
+
"""Return a boolean object mask from a tap ``seed`` = (x, y).
|
|
39
|
+
|
|
40
|
+
Base implementation: region grow flood fill in color space, capped so a
|
|
41
|
+
runaway grow can't select the whole frame.
|
|
42
|
+
"""
|
|
43
|
+
img = as_float_rgb(image)
|
|
44
|
+
return self._color_flood(img, seed, tolerance, max_area_frac)
|
|
45
|
+
|
|
46
|
+
def segment_box(self, image: np.ndarray, box: Tuple[int, int, int, int],
|
|
47
|
+
tolerance: float = 0.12) -> np.ndarray:
|
|
48
|
+
"""Segment the dominant object inside a box (x0, y0, x1, y1).
|
|
49
|
+
|
|
50
|
+
Seeds the flood from the box centre, a stand in for the model's
|
|
51
|
+
box prompt path.
|
|
52
|
+
"""
|
|
53
|
+
x0, y0, x1, y1 = box
|
|
54
|
+
seed = ((x0 + x1) // 2, (y0 + y1) // 2)
|
|
55
|
+
return self.segment(image, seed, tolerance)
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _color_flood(img: np.ndarray, seed: Tuple[int, int],
|
|
59
|
+
tolerance: float, max_area_frac: float) -> np.ndarray:
|
|
60
|
+
h, w = img.shape[:2]
|
|
61
|
+
seed_x, seed_y = int(seed[0]), int(seed[1])
|
|
62
|
+
mask = np.zeros((h, w), dtype=bool)
|
|
63
|
+
if not (0 <= seed_x < w and 0 <= seed_y < h):
|
|
64
|
+
return mask
|
|
65
|
+
seed_color = img[seed_y, seed_x]
|
|
66
|
+
tolerance_sq_threshold = tolerance * tolerance * 3.0 # sum over 3 channels
|
|
67
|
+
max_pixels = int(max_area_frac * h * w)
|
|
68
|
+
|
|
69
|
+
visited = np.zeros((h, w), dtype=bool)
|
|
70
|
+
frontier = deque([(seed_x, seed_y)])
|
|
71
|
+
visited[seed_y, seed_x] = True
|
|
72
|
+
count = 0
|
|
73
|
+
while frontier and count < max_pixels:
|
|
74
|
+
x, y = frontier.popleft()
|
|
75
|
+
diff = img[y, x] - seed_color
|
|
76
|
+
if float(diff.dot(diff)) > tolerance_sq_threshold:
|
|
77
|
+
continue
|
|
78
|
+
mask[y, x] = True
|
|
79
|
+
count += 1
|
|
80
|
+
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
|
|
81
|
+
if 0 <= nx < w and 0 <= ny < h and not visited[ny, nx]:
|
|
82
|
+
visited[ny, nx] = True
|
|
83
|
+
frontier.append((nx, ny))
|
|
84
|
+
return mask
|