object-remove 0.2.0__tar.gz → 0.2.2__tar.gz

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 (88) hide show
  1. {object_remove-0.2.0 → object_remove-0.2.2}/PKG-INFO +3 -1
  2. {object_remove-0.2.0 → object_remove-0.2.2}/README.md +2 -0
  3. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/__init__.py +1 -1
  4. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/orb.py +46 -50
  5. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/effects/perspective_correction.py +19 -0
  6. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver_gpu.py +35 -0
  7. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/quad_tree_index.py +13 -4
  8. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift.py +29 -11
  9. object_remove-0.2.2/object_remove/mask/connected_components.py +142 -0
  10. object_remove-0.2.2/object_remove/mask/scanline_selection.py +123 -0
  11. object_remove-0.2.2/object_remove/mask/selection_enhancer.py +253 -0
  12. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pipeline/remove_object_job.py +1 -1
  13. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove.egg-info/PKG-INFO +3 -1
  14. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove.egg-info/SOURCES.txt +1 -0
  15. {object_remove-0.2.0 → object_remove-0.2.2}/pyproject.toml +1 -1
  16. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_gpu.py +0 -11
  17. object_remove-0.2.2/tests/test_mask.py +131 -0
  18. object_remove-0.2.0/object_remove/mask/connected_components.py +0 -110
  19. object_remove-0.2.0/object_remove/mask/selection_enhancer.py +0 -127
  20. object_remove-0.2.0/tests/test_mask.py +0 -70
  21. {object_remove-0.2.0 → object_remove-0.2.2}/LICENSE +0 -0
  22. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/__init__.py +0 -0
  23. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/box_filter.py +0 -0
  24. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/canny.py +0 -0
  25. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/distance_transform.py +0 -0
  26. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/frequency_separation.py +0 -0
  27. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/harris.py +0 -0
  28. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/max_finder.py +0 -0
  29. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/nearest_opaque.py +0 -0
  30. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/cv/symmetric_convolution.py +0 -0
  31. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/effects/__init__.py +0 -0
  32. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/effects/background_blur.py +0 -0
  33. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/__init__.py +0 -0
  34. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/clone_stamp.py +0 -0
  35. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator.py +0 -0
  36. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator_gpu.py +0 -0
  37. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/__init__.py +0 -0
  38. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/group_solver.py +0 -0
  39. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch.py +0 -0
  40. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_scorer.py +0 -0
  41. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_search_index.py +0 -0
  42. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/solver.py +0 -0
  43. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/torch_solver.py +0 -0
  44. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/__init__.py +0 -0
  45. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver.py +0 -0
  46. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/renderer.py +0 -0
  47. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/primary/scoring.py +0 -0
  48. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift_gpu.py +0 -0
  49. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/fillengines/wire_removal.py +0 -0
  50. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/imageutil.py +0 -0
  51. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/mask/__init__.py +0 -0
  52. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/mask/auto_select.py +0 -0
  53. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/mask/brush_rasterizer.py +0 -0
  54. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/mask/polygon_selection.py +0 -0
  55. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pipeline/__init__.py +0 -0
  56. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/py.typed +0 -0
  57. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pyramid/__init__.py +0 -0
  58. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pyramid/image_pyramid.py +0 -0
  59. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pyramid/scale_scheduler.py +0 -0
  60. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pyramid/tile_scheduler.py +0 -0
  61. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/pyramid/torch_pyramid.py +0 -0
  62. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/__init__.py +0 -0
  63. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/compositor.py +0 -0
  64. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/fused_patch_blend.py +0 -0
  65. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/multiband_blender.py +0 -0
  66. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/multiband_blender_gpu.py +0 -0
  67. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/poisson_blender.py +0 -0
  68. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/render/poisson_blender_gpu.py +0 -0
  69. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/runtime/__init__.py +0 -0
  70. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/runtime/device.py +0 -0
  71. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/session/__init__.py +0 -0
  72. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/session/empty_session_sweeper.py +0 -0
  73. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove/session/undo_session_manager.py +0 -0
  74. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove.egg-info/dependency_links.txt +0 -0
  75. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove.egg-info/requires.txt +0 -0
  76. {object_remove-0.2.0 → object_remove-0.2.2}/object_remove.egg-info/top_level.txt +0 -0
  77. {object_remove-0.2.0 → object_remove-0.2.2}/setup.cfg +0 -0
  78. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_clone_stamp.py +0 -0
  79. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_cv.py +0 -0
  80. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_effects.py +0 -0
  81. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_fillengines.py +0 -0
  82. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_perspective_correction.py +0 -0
  83. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_pipeline.py +0 -0
  84. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_primary_fill_engine.py +0 -0
  85. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_pyramid.py +0 -0
  86. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_render.py +0 -0
  87. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_session.py +0 -0
  88. {object_remove-0.2.0 → object_remove-0.2.2}/tests/test_wire_removal.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: object-remove
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: From-scratch reference implementation of a tiered on-device object-removal pipeline, CPU and GPU
5
5
  Author: shalaga44
6
6
  License-Expression: MIT
@@ -31,6 +31,8 @@ Dynamic: license-file
31
31
 
32
32
  # object_remove
33
33
 
34
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/)
35
+
34
36
  A from scratch implementation of an on device object removal pipeline (see
35
37
  [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
36
38
  polygon/scanline mask layer, **four fill engines** of increasing
@@ -1,5 +1,7 @@
1
1
  # object_remove
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/)
4
+
3
5
  A from scratch implementation of an on device object removal pipeline (see
4
6
  [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
5
7
  polygon/scanline mask layer, **four fill engines** of increasing
@@ -15,7 +15,7 @@ Public entry point::
15
15
  import importlib
16
16
  from typing import Any
17
17
 
18
- __version__ = "0.1.0"
18
+ __version__ = "0.2.2"
19
19
 
20
20
  # Lazy attribute map: name maps to (submodule, attribute). Loaded on first access so
21
21
  # importing any subpackage never eagerly drags in the whole tree.
@@ -31,12 +31,15 @@ def fast_corners(gray: np.ndarray, valid: np.ndarray, threshold: float = 0.06,
31
31
  n_contig: int = 9, border: int = 16) -> List[Tuple[int, int]]:
32
32
  """FAST 9 corner detector over valid pixels only.
33
33
 
34
- The per pixel Python loop scales with total image area, not hole size,
35
- so it dominates on large photos. The 4 point compass quick reject (which
36
- only needs to prune candidates, not decide corners) is vectorised over
37
- the whole interior at once via shifted slices; the expensive full 16
38
- point contiguous arc test then runs only on the much smaller surviving
39
- candidate set, with identical semantics to the scalar version.
34
+ Both the 4 point compass quick reject and the full 16 point contiguous
35
+ arc test are vectorised over the whole interior at once via shifted
36
+ slices, rather than looping per candidate pixel in Python -- a Python
37
+ loop there would scale with total image area (not hole size), dominating
38
+ runtime on large photos. The arc test still needs a loop to walk the 16
39
+ (+n_contig-1 wrap-around) circle taps and accumulate the longest same
40
+ sign run, but that loop is a fixed ~24 iterations regardless of image
41
+ size, with each iteration a single vectorised array op over every
42
+ interior pixel at once -- identical semantics to the scalar version.
40
43
  """
41
44
  H, W = gray.shape
42
45
  circ = _FAST_CIRCLE
@@ -57,35 +60,26 @@ def fast_corners(gray: np.ndarray, valid: np.ndarray, threshold: float = 0.06,
57
60
  reject += (v > hi) | (v < lo)
58
61
 
59
62
  cand = (reject >= 3) & valid[iy0:iy1, ix0:ix1]
60
- cys, cxs = np.nonzero(cand)
61
-
62
- pts: List[Tuple[int, int]] = []
63
- for cy, cx in zip(cys, cxs):
64
- y, x = int(cy) + iy0, int(cx) + ix0
65
- center_val = gray[y, x]
66
- chi = center_val + threshold
67
- clo = center_val - threshold
68
- states = []
69
- for dx, dy in circ:
70
- v = gray[y + dy, x + dx]
71
- states.append(1 if v > chi else (-1 if v < clo else 0))
72
- if _has_contiguous(states, n_contig):
73
- pts.append((y, x))
74
- return pts
75
-
76
-
77
- def _has_contiguous(states: List[int], n: int) -> bool:
78
- ext = states + states[:n - 1]
63
+ if not cand.any():
64
+ return []
65
+
66
+ states = np.empty((len(circ),) + center.shape, dtype=np.int8)
67
+ for i, (dx, dy) in enumerate(circ):
68
+ v = shifted(dx, dy)
69
+ states[i] = np.where(v > hi, 1, np.where(v < lo, -1, 0))
70
+
71
+ # circular wrap-around, same as `states + states[:n_contig - 1]` in the
72
+ # scalar version's per-point list
73
+ ext = np.concatenate([states, states[:n_contig - 1]], axis=0)
74
+ found = np.zeros(center.shape, dtype=bool)
79
75
  for sgn in (1, -1):
80
- run = 0
81
- for s in ext:
82
- if s == sgn:
83
- run += 1
84
- if run >= n:
85
- return True
86
- else:
87
- run = 0
88
- return False
76
+ run = np.zeros(center.shape, dtype=np.int32)
77
+ for i in range(ext.shape[0]):
78
+ run = np.where(ext[i] == sgn, run + 1, 0)
79
+ found |= run >= n_contig
80
+
81
+ cys, cxs = np.nonzero(cand & found)
82
+ return [(int(cy) + iy0, int(cx) + ix0) for cy, cx in zip(cys, cxs)]
89
83
 
90
84
 
91
85
  def _brief_pattern(patch: int = 15, n_bits: int = 256, seed: int = 42) -> np.ndarray:
@@ -97,26 +91,28 @@ _BRIEF = _brief_pattern()
97
91
 
98
92
 
99
93
  def brief_descriptors(gray: np.ndarray, pts: List[Tuple[int, int]]) -> List[Keypoint]:
100
- """256 bit BRIEF on a smoothed image, packed into 32 uint8 bytes."""
94
+ """256 bit BRIEF on a smoothed image, packed into 32 uint8 bytes.
95
+
96
+ Bit computation is vectorised across all points *and* all 256 pattern
97
+ taps at once (one (N, 256) gather + compare + packbits), instead of a
98
+ per point Python loop each doing its own small array ops -- per call
99
+ overhead there dominates runtime for images with many keypoints.
100
+ """
101
+ if not pts:
102
+ return []
101
103
  sm = gaussian_blur(gray)
102
104
  H, W = sm.shape
103
- out: List[Keypoint] = []
104
105
  pat = _BRIEF
105
- # Per point bit computation is vectorised across all 256 pattern taps at
106
- # once: the equivalent scalar per bit loop spends almost all its time in
107
- # per element np.clip() call overhead (measured ~19x slower than a single
108
- # array clip of the same size), which dominates runtime for images with
109
- # many keypoints.
106
+ pys = np.asarray([p[0] for p in pts], dtype=np.int64)[:, None]
107
+ pxs = np.asarray([p[1] for p in pts], dtype=np.int64)[:, None]
110
108
  pax, pay, pbx, pby = pat[:, 0], pat[:, 1], pat[:, 2], pat[:, 3]
111
- for (y, x) in pts:
112
- ay = np.clip(y + pay, 0, H - 1)
113
- ax = np.clip(x + pax, 0, W - 1)
114
- by = np.clip(y + pby, 0, H - 1)
115
- bx = np.clip(x + pbx, 0, W - 1)
116
- bits = (sm[ay, ax] < sm[by, bx]).astype(np.uint8)
117
- packed = np.packbits(bits)
118
- out.append(Keypoint(y, x, packed))
119
- return out
109
+ ay = np.clip(pys + pay[None, :], 0, H - 1)
110
+ ax = np.clip(pxs + pax[None, :], 0, W - 1)
111
+ by = np.clip(pys + pby[None, :], 0, H - 1)
112
+ bx = np.clip(pxs + pbx[None, :], 0, W - 1)
113
+ bits = (sm[ay, ax] < sm[by, bx]).astype(np.uint8) # (N, 256)
114
+ packed = np.packbits(bits, axis=1) # (N, 32)
115
+ return [Keypoint(y, x, packed[i]) for i, (y, x) in enumerate(pts)]
120
116
 
121
117
 
122
118
  def hamming(a: np.ndarray, b: np.ndarray) -> np.ndarray:
@@ -99,6 +99,25 @@ def _bilinear_sample(image: np.ndarray, xs: np.ndarray, ys: np.ndarray) -> np.nd
99
99
  return out
100
100
 
101
101
 
102
+ def bilinear_resize(image: np.ndarray, out_shape: Tuple[int, int]) -> np.ndarray:
103
+ """Plain bilinear resize to ``out_shape`` (oh, ow) -- pixel-center mapping
104
+ with clamped borders, not a homography warp. Shared by callers that need
105
+ an arbitrary-scale resize (e.g. the selection enhancer's pyramid blur)
106
+ without duplicating the bilinear-sample math."""
107
+ ih, iw = image.shape[:2]
108
+ oh, ow = out_shape
109
+ yy, xx = np.mgrid[0:oh, 0:ow]
110
+ src_x = np.clip((xx.astype(np.float64) + 0.5) * (iw / ow) - 0.5, 0.0, iw - 1)
111
+ src_y = np.clip((yy.astype(np.float64) + 0.5) * (ih / oh) - 0.5, 0.0, ih - 1)
112
+ img = image.astype(np.float64)
113
+ squeeze = img.ndim == 2
114
+ if squeeze:
115
+ img = img[:, :, None]
116
+ sampled = _bilinear_sample(img, src_x.ravel(), src_y.ravel())
117
+ sampled = sampled.reshape(oh, ow, -1)
118
+ return sampled[:, :, 0] if squeeze else sampled
119
+
120
+
102
121
  def map_image(image: np.ndarray, H: np.ndarray, out_shape: Tuple[int, int]) -> np.ndarray:
103
122
  """Warp ``image`` through ``H`` (src -> dst) into an image of
104
123
  ``out_shape``, via inverse mapping + bilinear sampling."""
@@ -52,6 +52,37 @@ def _gather_windows(field: torch.Tensor, centers: torch.Tensor, r: int) -> torch
52
52
  return field[ys, xs]
53
53
 
54
54
 
55
+ def _local_src_mask(source_coords: torch.Tensor, base_mask: torch.Tensor,
56
+ layer_centers: torch.Tensor, margin0: int, cap: int) -> torch.Tensor:
57
+ """Restrict `base_mask` (already reuse-filtered) to a spatial window
58
+ around this layer's own centres, doubling the window until it actually
59
+ contains a candidate (or giving up at `cap` and returning `base_mask`
60
+ unrestricted). Nearby sources are always at least as good a match as far
61
+ ones for real photos, so this changes only which near-tied candidate wins
62
+ - not whether a good one is found.
63
+
64
+ Without this, every layer's (T, S) search scans every valid pixel in the
65
+ whole image (`_best_sources_batch` below), which is fine for a small hole
66
+ but is the dominant cost for a thick one: a hole this solver treats as
67
+ ``H`` boundary-distance layers deep (see `_hole_layers`) does that full
68
+ scan ``H`` times, and a hole a few hundred pixels thick (e.g. a wide,
69
+ mostly-flat padding bar) turns into a multi-minute solve on a search that
70
+ only ever needed its own neighbourhood."""
71
+ cy0 = int(layer_centers[:, 0].min())
72
+ cy1 = int(layer_centers[:, 0].max())
73
+ cx0 = int(layer_centers[:, 1].min())
74
+ cx1 = int(layer_centers[:, 1].max())
75
+ margin = margin0
76
+ while margin < cap:
77
+ spatial = ((source_coords[:, 0] >= cy0 - margin) & (source_coords[:, 0] <= cy1 + margin) &
78
+ (source_coords[:, 1] >= cx0 - margin) & (source_coords[:, 1] <= cx1 + margin))
79
+ combined = base_mask & spatial
80
+ if combined.any():
81
+ return combined
82
+ margin *= 2
83
+ return base_mask
84
+
85
+
55
86
  def _best_sources_batch(working: torch.Tensor, known: torch.Tensor,
56
87
  target_centers: torch.Tensor, source_coords: torch.Tensor,
57
88
  src_mask: torch.Tensor, r: int):
@@ -136,6 +167,8 @@ class TorchPrimaryFillEngineImpl:
136
167
  records: List[PatchCopyRecord] = []
137
168
  total = max(1, len(centers_np))
138
169
  processed = 0
170
+ local_margin0 = max(4 * r, 16)
171
+ local_cap = max(H, W)
139
172
 
140
173
  for layer_id in np.unique(center_layers):
141
174
  if cancel is not None and cancel():
@@ -144,6 +177,8 @@ class TorchPrimaryFillEngineImpl:
144
177
  layer_centers = centers[idx]
145
178
 
146
179
  src_mask = reuse_count < self.max_reuse
180
+ src_mask = _local_src_mask(source_coords, src_mask, layer_centers,
181
+ local_margin0, local_cap)
147
182
  best_idx, best_dist = _best_sources_batch(
148
183
  working, known, layer_centers, source_coords, src_mask, r)
149
184
 
@@ -85,8 +85,10 @@ class QuadTreeIndex:
85
85
  yield from self._iter_points(c)
86
86
 
87
87
  def all_points(self) -> np.ndarray:
88
- idx = list(self._iter_points(self.root))
89
- return self.coords[idx] if idx else np.empty((0, 2), dtype=np.int64)
88
+ # every index gets inserted into exactly one leaf at construction
89
+ # time (see __init__/_insert), so the full point set is always just
90
+ # self.coords -- no need to walk the tree to reconstruct it.
91
+ return self.coords
90
92
 
91
93
  def query_range(self, y0: int, x0: int, y1: int, x1: int) -> np.ndarray:
92
94
  """Points whose centre lies in ``[y0,y1) x [x0,x1)``, pruning whole
@@ -122,8 +124,15 @@ class QuadTreeIndex:
122
124
  ) -> Optional[Tuple[int, int]]:
123
125
  coords = self.all_points()
124
126
  if used and len(coords):
125
- reuse_count = np.array([used.get((int(y), int(x)), 0) for y, x in coords])
126
- coords = coords[reuse_count < max_reuse]
127
+ # linearise (y, x) -> y*W+x so the per-point dict.get in a Python
128
+ # loop becomes one vectorised membership test against just the
129
+ # (typically far smaller) set of sources that are actually
130
+ # over-used, rather than looping over every candidate in Python.
131
+ W = self.root.x1
132
+ overused = [k[0] * W + k[1] for k, v in used.items() if v >= max_reuse]
133
+ if overused:
134
+ coord_idx = coords[:, 0] * W + coords[:, 1]
135
+ coords = coords[~np.isin(coord_idx, overused)]
127
136
  return _best_match(image, ty, tx, coords, radius, target_valid)
128
137
 
129
138
 
@@ -72,18 +72,27 @@ def _similar_far_offsets(kps: List[Keypoint], hole_bbox,
72
72
  # DP seam carving + transpose trick
73
73
  # ==========================================================================
74
74
  def min_cost_seam(energy: np.ndarray) -> np.ndarray:
75
- """Min cost vertical seam via DP. Returns one column index per row."""
75
+ """Min cost vertical seam via DP. Returns one column index per row.
76
+
77
+ Row y only depends on row y-1, so the DP itself must stay a sequential
78
+ Python loop over rows; but within a row, each column's 3 way choice
79
+ (up-left/up/up-right) is independent of its neighbours' choices and is
80
+ vectorised across the whole row at once (edges padded with +inf so they
81
+ naturally fall back to the 2 candidate case), instead of an inner
82
+ per-column Python loop -- same argmin tie-breaking (favours the
83
+ lower-index candidate) as the scalar version.
84
+ """
76
85
  h, w = energy.shape
77
86
  cost = energy.astype(np.float64).copy()
78
87
  back = np.zeros((h, w), dtype=np.int32)
88
+ xs = np.arange(w)
79
89
  for y in range(1, h):
80
- for x in range(w):
81
- xl = max(0, x - 1)
82
- xr = min(w - 1, x + 1)
83
- choices = cost[y - 1, xl:xr + 1]
84
- k = int(np.argmin(choices))
85
- back[y, x] = xl + k
86
- cost[y, x] += choices[k]
90
+ prev = cost[y - 1]
91
+ padded = np.concatenate(([np.inf], prev, [np.inf]))
92
+ choices = np.stack([padded[0:w], padded[1:w + 1], padded[2:w + 2]]) # (3, w)
93
+ k = np.argmin(choices, axis=0)
94
+ back[y] = np.clip(xs + (k - 1), 0, w - 1)
95
+ cost[y] += choices[k, xs]
87
96
  seam = np.zeros(h, dtype=np.int32)
88
97
  seam[-1] = int(np.argmin(cost[-1]))
89
98
  for y in range(h - 2, -1, -1):
@@ -267,6 +276,12 @@ class SelfSimilarShiftEngine:
267
276
  ry0, ry1 = rys.min(), rys.max() + 1
268
277
  rx0, rx1 = rxs.min(), rxs.max() + 1
269
278
  out = np.zeros((H, W), dtype=bool)
279
+ # The DP always spans the same fixed row range (ry0:ry1); only the
280
+ # column bounds change with each row's hole extent. Rows sharing the
281
+ # same (strip_x0, strip_x1) -- typical for straight-ish hole edges --
282
+ # would otherwise resolve the identical DP from scratch once per row
283
+ # just to read off a single seam[yy] value, so cache by those bounds.
284
+ seam_cache: dict = {}
270
285
  for y in range(ry0, ry1):
271
286
  hx = np.nonzero(hole[y])[0]
272
287
  if hx.size == 0:
@@ -277,9 +292,12 @@ class SelfSimilarShiftEngine:
277
292
  strip_x0, strip_x1 = hx.max() + 1, rx1
278
293
  if strip_x1 <= strip_x0:
279
294
  continue
280
- strip = diff[ry0:ry1, strip_x0:strip_x1]
281
- # cheap per row seam via cumulative min DP done once over the strip
282
- seam = min_cost_seam(strip)
295
+ key = (strip_x0, strip_x1)
296
+ seam = seam_cache.get(key)
297
+ if seam is None:
298
+ strip = diff[ry0:ry1, strip_x0:strip_x1]
299
+ seam = min_cost_seam(strip)
300
+ seam_cache[key] = seam
283
301
  yy = y - ry0
284
302
  cut = strip_x0 + seam[yy]
285
303
  if axis == "left":
@@ -0,0 +1,142 @@
1
+ """Connected component labelling.
2
+
3
+ Labelling is a from scratch run-length union find (no
4
+ ``scipy.ndimage.label``): each row is decomposed into contiguous True runs,
5
+ union-find operates over runs rather than individual pixels (a filled region
6
+ has orders of magnitude fewer runs than pixels), and the final label write
7
+ back is one vectorized numpy slice assignment per run. The per region
8
+ *extraction* step is fanned out across a bounded thread pool so splitting a
9
+ selection into its components stays fast on large masks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from typing import Dict, List, Tuple
16
+
17
+ import numpy as np
18
+
19
+
20
+ class _UnionFind:
21
+ __slots__ = ("parent",)
22
+
23
+ def __init__(self, n: int):
24
+ self.parent = list(range(n))
25
+
26
+ def find(self, x: int) -> int:
27
+ root = x
28
+ while self.parent[root] != root:
29
+ root = self.parent[root]
30
+ while self.parent[x] != root:
31
+ self.parent[x], x = root, self.parent[x]
32
+ return root
33
+
34
+ def union(self, a: int, b: int) -> None:
35
+ ra, rb = self.find(a), self.find(b)
36
+ if ra != rb:
37
+ self.parent[max(ra, rb)] = min(ra, rb)
38
+
39
+
40
+ def _row_runs(mask: np.ndarray) -> List[List[Tuple[int, int]]]:
41
+ """Per-row list of inclusive ``(start, end)`` True-run spans."""
42
+ h = mask.shape[0]
43
+ runs: List[List[Tuple[int, int]]] = []
44
+ for y in range(h):
45
+ row = mask[y]
46
+ if not row.any():
47
+ runs.append([])
48
+ continue
49
+ idx = np.flatnonzero(np.diff(np.concatenate(([0], row.view(np.int8), [0]))))
50
+ runs.append([(int(a), int(b) - 1) for a, b in zip(idx[0::2], idx[1::2])])
51
+ return runs
52
+
53
+
54
+ def _label_dense(mask: np.ndarray, connectivity: int) -> Tuple[np.ndarray, int]:
55
+ """Run-based labelling over an already-cropped-to-content array."""
56
+ h, w = mask.shape
57
+ labels = np.zeros((h, w), dtype=np.int32)
58
+ row_runs = _row_runs(mask)
59
+
60
+ run_id: List[List[int]] = []
61
+ next_id = 0
62
+ for runs in row_runs:
63
+ run_id.append(list(range(next_id, next_id + len(runs))))
64
+ next_id += len(runs)
65
+ if next_id == 0:
66
+ return labels, 0
67
+ uf = _UnionFind(next_id)
68
+
69
+ diag = connectivity == 8
70
+ for y in range(1, h):
71
+ prev_runs, cur_runs = row_runs[y - 1], row_runs[y]
72
+ if not prev_runs or not cur_runs:
73
+ continue
74
+ i = j = 0
75
+ while i < len(prev_runs) and j < len(cur_runs):
76
+ p0, p1 = prev_runs[i]
77
+ c0, c1 = cur_runs[j]
78
+ touches = (p0 <= c1 + 1 and c0 <= p1 + 1) if diag else (p0 <= c1 and c0 <= p1)
79
+ if touches:
80
+ uf.union(run_id[y - 1][i], run_id[y][j])
81
+ if p1 < c1:
82
+ i += 1
83
+ else:
84
+ j += 1
85
+
86
+ remap: Dict[int, int] = {}
87
+ count = 0
88
+ for y, runs in enumerate(row_runs):
89
+ for j, (x0, x1) in enumerate(runs):
90
+ root = uf.find(run_id[y][j])
91
+ lab = remap.get(root)
92
+ if lab is None:
93
+ count += 1
94
+ lab = count
95
+ remap[root] = lab
96
+ labels[y, x0:x1 + 1] = lab
97
+ return labels, count
98
+
99
+
100
+ def label_components(mask: np.ndarray, connectivity: int = 8) -> Tuple[np.ndarray, int]:
101
+ """Run-length union find labelling. Returns (labels, count).
102
+
103
+ ``labels`` is int32, 0 = background, 1..count = components. Cropped to
104
+ the mask's bounding box internally (a selection is usually a small
105
+ region of a much larger canvas) before labelling, then placed back.
106
+ """
107
+ mask = np.ascontiguousarray(mask > 0)
108
+ h, w = mask.shape
109
+ labels = np.zeros((h, w), dtype=np.int32)
110
+ if not mask.any():
111
+ return labels, 0
112
+ row_any = np.flatnonzero(mask.any(axis=1))
113
+ col_any = np.flatnonzero(mask.any(axis=0))
114
+ y0, y1 = int(row_any[0]), int(row_any[-1])
115
+ x0, x1 = int(col_any[0]), int(col_any[-1])
116
+ sub_labels, count = _label_dense(mask[y0:y1 + 1, x0:x1 + 1], connectivity)
117
+ labels[y0:y1 + 1, x0:x1 + 1] = sub_labels
118
+ return labels, count
119
+
120
+
121
+ def split_components(mask: np.ndarray, max_selections: int = 0,
122
+ min_area: int = 1, workers: int = 4) -> List[np.ndarray]:
123
+ """Split a mask into per component boolean masks.
124
+
125
+ Extraction is fanned across a bounded worker pool. ``max_selections`` (>0)
126
+ keeps only the largest N components; ``min_area`` drops specks.
127
+ """
128
+ labels, n = label_components(mask)
129
+ if n == 0:
130
+ return []
131
+
132
+ def extract(lab: int) -> np.ndarray:
133
+ return labels == lab
134
+
135
+ with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
136
+ comps = list(ex.map(extract, range(1, n + 1)))
137
+
138
+ comps = [c for c in comps if int(c.sum()) >= min_area]
139
+ comps.sort(key=lambda c: int(c.sum()), reverse=True)
140
+ if max_selections and max_selections > 0:
141
+ comps = comps[:max_selections]
142
+ return comps
@@ -0,0 +1,123 @@
1
+ """Scanline/run-length selection model.
2
+
3
+ A selection is a list of rows, each holding a sorted list of
4
+ non-overlapping, non-adjacent inclusive ``(start, end)`` column spans.
5
+ ``add_region`` inserts a span via binary search and absorbs any spans that
6
+ overlap or touch it. This is the data structure ``SelectionEnhancer.
7
+ process_selection`` uses for the 15%-85% object-separation overlap gate.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import bisect
13
+ from typing import List, Tuple
14
+
15
+ import numpy as np
16
+
17
+ Span = Tuple[int, int]
18
+
19
+
20
+ class ScanlineSelection:
21
+ """Run-length selection over a fixed ``(height, width)`` canvas."""
22
+
23
+ def __init__(self, height: int, width: int):
24
+ self.height = height
25
+ self.width = width
26
+ self.rows: List[List[Span]] = [[] for _ in range(height)]
27
+
28
+ def add_region(self, row: int, x0: int, x1: int) -> None:
29
+ """Insert inclusive span ``[x0, x1]`` into ``row``, merging with any
30
+ overlapping or adjacent existing spans."""
31
+ if row < 0 or row >= self.height or x1 < x0:
32
+ return
33
+ x0 = max(0, x0)
34
+ x1 = min(self.width - 1, x1)
35
+ if x1 < x0:
36
+ return
37
+ spans = self.rows[row]
38
+ starts = [s[0] for s in spans]
39
+ idx = bisect.bisect_left(starts, x0)
40
+ j = idx
41
+ while j > 0 and spans[j - 1][1] >= x0 - 1:
42
+ j -= 1
43
+ k = idx
44
+ while k < len(spans) and spans[k][0] <= x1 + 1:
45
+ k += 1
46
+ new_start, new_end = x0, x1
47
+ if j < k:
48
+ new_start = min(new_start, spans[j][0])
49
+ new_end = max(new_end, spans[k - 1][1])
50
+ spans[j:k] = [(new_start, new_end)]
51
+
52
+ @classmethod
53
+ def from_mask(cls, mask: np.ndarray) -> "ScanlineSelection":
54
+ h, w = mask.shape
55
+ sel = cls(h, w)
56
+ mask = mask > 0
57
+ for y in range(h):
58
+ row = mask[y]
59
+ if not row.any():
60
+ continue
61
+ idx = np.flatnonzero(np.diff(np.concatenate(([0], row.view(np.int8), [0]))))
62
+ spans = [(int(a), int(b) - 1) for a, b in zip(idx[0::2], idx[1::2])]
63
+ sel.rows[y] = spans
64
+ return sel
65
+
66
+ def to_mask(self) -> np.ndarray:
67
+ mask = np.zeros((self.height, self.width), dtype=bool)
68
+ for y, spans in enumerate(self.rows):
69
+ for x0, x1 in spans:
70
+ mask[y, x0:x1 + 1] = True
71
+ return mask
72
+
73
+ def intersect(self, other: "ScanlineSelection") -> "ScanlineSelection":
74
+ """Per-row interval intersection."""
75
+ out = ScanlineSelection(self.height, self.width)
76
+ for y in range(min(self.height, other.height)):
77
+ a, b = self.rows[y], other.rows[y]
78
+ i = j = 0
79
+ result: List[Span] = []
80
+ while i < len(a) and j < len(b):
81
+ lo = max(a[i][0], b[j][0])
82
+ hi = min(a[i][1], b[j][1])
83
+ if lo <= hi:
84
+ result.append((lo, hi))
85
+ if a[i][1] < b[j][1]:
86
+ i += 1
87
+ else:
88
+ j += 1
89
+ out.rows[y] = result
90
+ return out
91
+
92
+ def difference(self, other: "ScanlineSelection") -> "ScanlineSelection":
93
+ """Per-row interval subtraction: ``self`` minus ``other``."""
94
+ out = ScanlineSelection(self.height, self.width)
95
+ for y in range(self.height):
96
+ a = self.rows[y]
97
+ b = other.rows[y] if y < other.height else []
98
+ result: List[Span] = []
99
+ for x0, x1 in a:
100
+ cur = x0
101
+ for bx0, bx1 in b:
102
+ if bx1 < cur or bx0 > x1:
103
+ continue
104
+ if bx0 > cur:
105
+ result.append((cur, min(bx0 - 1, x1)))
106
+ cur = max(cur, bx1 + 1)
107
+ if cur > x1:
108
+ break
109
+ if cur <= x1:
110
+ result.append((cur, x1))
111
+ out.rows[y] = result
112
+ return out
113
+
114
+ def total_length(self) -> int:
115
+ return sum(x1 - x0 + 1 for spans in self.rows for x0, x1 in spans)
116
+
117
+ def row_overlap_ratio(self, other: "ScanlineSelection") -> float:
118
+ """Percentage of ``self``'s covered columns also covered by
119
+ ``other`` (used by the 15%-85% object-separation gate)."""
120
+ total = self.total_length()
121
+ if total == 0:
122
+ return 0.0
123
+ return self.intersect(other).total_length() / total * 100.0