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