object-remove 0.2.1__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.1 → object_remove-0.2.2}/PKG-INFO +2 -2
  2. {object_remove-0.2.1 → object_remove-0.2.2}/README.md +1 -1
  3. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/__init__.py +1 -1
  4. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/perspective_correction.py +19 -0
  5. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver_gpu.py +35 -0
  6. object_remove-0.2.2/object_remove/mask/connected_components.py +142 -0
  7. object_remove-0.2.2/object_remove/mask/scanline_selection.py +123 -0
  8. object_remove-0.2.2/object_remove/mask/selection_enhancer.py +253 -0
  9. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pipeline/remove_object_job.py +1 -1
  10. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/PKG-INFO +2 -2
  11. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/SOURCES.txt +1 -0
  12. {object_remove-0.2.1 → object_remove-0.2.2}/pyproject.toml +1 -1
  13. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_gpu.py +0 -11
  14. object_remove-0.2.2/tests/test_mask.py +131 -0
  15. object_remove-0.2.1/object_remove/mask/connected_components.py +0 -110
  16. object_remove-0.2.1/object_remove/mask/selection_enhancer.py +0 -127
  17. object_remove-0.2.1/tests/test_mask.py +0 -70
  18. {object_remove-0.2.1 → object_remove-0.2.2}/LICENSE +0 -0
  19. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/__init__.py +0 -0
  20. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/box_filter.py +0 -0
  21. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/canny.py +0 -0
  22. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/distance_transform.py +0 -0
  23. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/frequency_separation.py +0 -0
  24. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/harris.py +0 -0
  25. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/max_finder.py +0 -0
  26. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/nearest_opaque.py +0 -0
  27. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/orb.py +0 -0
  28. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/symmetric_convolution.py +0 -0
  29. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/__init__.py +0 -0
  30. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/background_blur.py +0 -0
  31. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/__init__.py +0 -0
  32. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/clone_stamp.py +0 -0
  33. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator.py +0 -0
  34. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator_gpu.py +0 -0
  35. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/__init__.py +0 -0
  36. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/group_solver.py +0 -0
  37. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch.py +0 -0
  38. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_scorer.py +0 -0
  39. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_search_index.py +0 -0
  40. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/solver.py +0 -0
  41. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/torch_solver.py +0 -0
  42. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/__init__.py +0 -0
  43. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver.py +0 -0
  44. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/quad_tree_index.py +0 -0
  45. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/renderer.py +0 -0
  46. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/scoring.py +0 -0
  47. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift.py +0 -0
  48. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift_gpu.py +0 -0
  49. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/wire_removal.py +0 -0
  50. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/imageutil.py +0 -0
  51. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/__init__.py +0 -0
  52. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/auto_select.py +0 -0
  53. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/brush_rasterizer.py +0 -0
  54. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/polygon_selection.py +0 -0
  55. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pipeline/__init__.py +0 -0
  56. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/py.typed +0 -0
  57. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/__init__.py +0 -0
  58. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/image_pyramid.py +0 -0
  59. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/scale_scheduler.py +0 -0
  60. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/tile_scheduler.py +0 -0
  61. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/torch_pyramid.py +0 -0
  62. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/__init__.py +0 -0
  63. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/compositor.py +0 -0
  64. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/fused_patch_blend.py +0 -0
  65. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/multiband_blender.py +0 -0
  66. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/multiband_blender_gpu.py +0 -0
  67. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/poisson_blender.py +0 -0
  68. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/poisson_blender_gpu.py +0 -0
  69. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/runtime/__init__.py +0 -0
  70. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/runtime/device.py +0 -0
  71. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/__init__.py +0 -0
  72. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/empty_session_sweeper.py +0 -0
  73. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/undo_session_manager.py +0 -0
  74. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/dependency_links.txt +0 -0
  75. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/requires.txt +0 -0
  76. {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/top_level.txt +0 -0
  77. {object_remove-0.2.1 → object_remove-0.2.2}/setup.cfg +0 -0
  78. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_clone_stamp.py +0 -0
  79. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_cv.py +0 -0
  80. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_effects.py +0 -0
  81. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_fillengines.py +0 -0
  82. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_perspective_correction.py +0 -0
  83. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_pipeline.py +0 -0
  84. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_primary_fill_engine.py +0 -0
  85. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_pyramid.py +0 -0
  86. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_render.py +0 -0
  87. {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_session.py +0 -0
  88. {object_remove-0.2.1 → 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.1
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,7 +31,7 @@ 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/0.2.1/)
34
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/)
35
35
 
36
36
  A from scratch implementation of an on device object removal pipeline (see
37
37
  [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
@@ -1,6 +1,6 @@
1
1
  # object_remove
2
2
 
3
- [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/0.2.1/)
3
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/)
4
4
 
5
5
  A from scratch implementation of an on device object removal pipeline (see
6
6
  [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
@@ -15,7 +15,7 @@ Public entry point::
15
15
  import importlib
16
16
  from typing import Any
17
17
 
18
- __version__ = "0.2.1"
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.
@@ -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
 
@@ -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
@@ -0,0 +1,253 @@
1
+ """Selection refinement operations.
2
+
3
+ * ``denoise_and_smooth`` — jaggy edge cleanup via morphological open/close,
4
+ iteration count knob only.
5
+ * ``soften`` — feathering, ``radius`` + ``iterations`` knobs, returns a *soft*
6
+ (float) mask, a distinct operation from denoise.
7
+ * ``split_into_selections`` — threaded connected component split (delegates to
8
+ :mod:`connected_components`).
9
+ * ``split_touching_objects`` — explicit object separation, a distinct entry
10
+ point from the quick split above.
11
+ * ``exclude_region`` / ``erode_by_radius`` — carve a forbidden area out of a
12
+ selection, and per sub selection erosion.
13
+ * ``process_selection`` — full refinement pipeline: mesh thickness estimate,
14
+ resolution scaled smoothing, gated object separation, boundary-bridge
15
+ softening.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import List, Optional
21
+
22
+ import numpy as np
23
+
24
+ from .connected_components import split_components, label_components
25
+ from .polygon_selection import PolygonSelection
26
+ from .brush_rasterizer import BrushRasterizer
27
+ from .scanline_selection import ScanlineSelection
28
+ from ..effects.perspective_correction import bilinear_resize
29
+
30
+
31
+ def _binary_dilate(mask: np.ndarray, iterations: int = 1) -> np.ndarray:
32
+ m = mask.copy()
33
+ for _ in range(iterations):
34
+ out = m.copy()
35
+ out[1:, :] |= m[:-1, :]
36
+ out[:-1, :] |= m[1:, :]
37
+ out[:, 1:] |= m[:, :-1]
38
+ out[:, :-1] |= m[:, 1:]
39
+ m = out
40
+ return m
41
+
42
+
43
+ def _binary_erode(mask: np.ndarray, iterations: int = 1) -> np.ndarray:
44
+ m = mask.copy()
45
+ for _ in range(iterations):
46
+ out = m.copy()
47
+ out[1:, :] &= m[:-1, :]
48
+ out[:-1, :] &= m[1:, :]
49
+ out[:, 1:] &= m[:, :-1]
50
+ out[:, :-1] &= m[:, 1:]
51
+ # treat outside as empty, so borders erode away
52
+ out[0, :] &= m[0, :]
53
+ m = out
54
+ return m
55
+
56
+
57
+ def _box_blur(img: np.ndarray, radius: int) -> np.ndarray:
58
+ """Separable box blur via cumulative sums (float input/output)."""
59
+ if radius <= 0:
60
+ return img
61
+ pad = radius
62
+ win = 2 * radius + 1
63
+ padded = np.pad(img, pad, mode="edge")
64
+ # horizontal sliding window mean (returns width back to original W)
65
+ cs = np.cumsum(padded, axis=1)
66
+ cs = np.pad(cs, ((0, 0), (1, 0)), mode="constant")
67
+ padded = (cs[:, win:] - cs[:, :-win]) / win
68
+ # vertical sliding window mean (returns height back to original H)
69
+ cs = np.cumsum(padded, axis=0)
70
+ cs = np.pad(cs, ((1, 0), (0, 0)), mode="constant")
71
+ padded = (cs[win:, :] - cs[:-win, :]) / win
72
+ return padded
73
+
74
+
75
+ class SelectionEnhancer:
76
+ """Refinement operations over boolean/soft masks."""
77
+
78
+ def denoise_and_smooth(self, mask: np.ndarray, iterations: int = 1) -> np.ndarray:
79
+ """Morphological open then close — removes specks and fills pinholes.
80
+
81
+ Iteration count knob only (distinct from :meth:`soften`).
82
+ """
83
+ m = mask > 0
84
+ for _ in range(max(1, iterations)):
85
+ m = _binary_dilate(_binary_erode(m)) # open
86
+ m = _binary_erode(_binary_dilate(m)) # close
87
+ return m
88
+
89
+ def soften(self, mask: np.ndarray, radius: float = 3.0,
90
+ iterations: int = 1) -> np.ndarray:
91
+ """Feather the mask edge; returns a soft float mask in [0, 1].
92
+
93
+ ``radius`` + ``iterations`` knobs — a different operation from
94
+ denoise. Repeated box blur approximates a Gaussian feather.
95
+ """
96
+ soft = (mask > 0).astype(np.float32)
97
+ r = max(1, int(round(radius)))
98
+ for _ in range(max(1, iterations)):
99
+ soft = _box_blur(soft, r).astype(np.float32)
100
+ return np.clip(soft, 0.0, 1.0)
101
+
102
+ def erode_by_radius(self, mask: np.ndarray, radius: int = 1) -> np.ndarray:
103
+ """Erode the mask edge inward by ``radius``."""
104
+ return _binary_erode(mask > 0, iterations=max(1, radius))
105
+
106
+ def split_into_selections(self, mask: np.ndarray, max_selections: int = 0,
107
+ min_area: int = 1, workers: int = 4) -> List[np.ndarray]:
108
+ """Split into sub selections across a bounded worker pool."""
109
+ return split_components(mask, max_selections=max_selections,
110
+ min_area=min_area, workers=workers)
111
+
112
+ def split_touching_objects(self, mask: np.ndarray, min_area: int = 1) -> List[np.ndarray]:
113
+ """Explicit object separation, a distinct entry point from the quick
114
+ split above.
115
+
116
+ Separates touching but thin connections with an erosion before
117
+ labelling, then dilates each object back to its original extent (a
118
+ watershed lite).
119
+ """
120
+ core = _binary_erode(mask > 0, iterations=1)
121
+ comps = split_components(core, min_area=min_area)
122
+ result = []
123
+ for c in comps:
124
+ grown = _binary_dilate(c, iterations=1) & (mask > 0)
125
+ result.append(grown)
126
+ return result
127
+
128
+ def exclude_region(self, mask: np.ndarray, forbidden: np.ndarray) -> np.ndarray:
129
+ """Remove a forbidden region from a selection.
130
+
131
+ The forbidden mask marks pixels that must never be edited or used
132
+ as a fill source.
133
+ """
134
+ return (mask > 0) & ~(forbidden > 0)
135
+
136
+ def estimate_thickness(self, mask: np.ndarray) -> int:
137
+ """How "thick" the selection is, 0..30ish.
138
+
139
+ Resamples the selection at ``scale = min(4000 / max(H, W), 1.0)`` and
140
+ reads back the max antialiased coverage byte; a thin sliver loses
141
+ coverage under downsampling while a solid blob stays near 255.
142
+ """
143
+ h, w = mask.shape
144
+ max_dim = max(h, w)
145
+ if max_dim == 0:
146
+ return 0
147
+ scale = min(4000.0 / max_dim, 1.0)
148
+ new_h = max(1, int(round(h * scale)))
149
+ new_w = max(1, int(round(w * scale)))
150
+ coverage = bilinear_resize((mask > 0).astype(np.float64), (new_h, new_w))
151
+ max_byte = float(coverage.max()) * 255.0 if coverage.size else 0.0
152
+ return int(max_byte / 8.5)
153
+
154
+ def _pyramid_smooth(self, mask: np.ndarray, levels: int) -> np.ndarray:
155
+ """``levels`` rounds of halving-downsample then upsample-back via
156
+ bilinear resize, thresholded back to boolean -- a cheap mip-chain
157
+ blur that scales with selection size instead of a fixed kernel."""
158
+ h, w = mask.shape
159
+ sizes = [(h, w)]
160
+ cur_h, cur_w = h, w
161
+ for _ in range(max(1, levels)):
162
+ cur_h = max(1, cur_h // 2)
163
+ cur_w = max(1, cur_w // 2)
164
+ sizes.append((cur_h, cur_w))
165
+ img = (mask > 0).astype(np.float64)
166
+ for size in sizes[1:]:
167
+ img = bilinear_resize(img, size)
168
+ for size in reversed(sizes[:-1]):
169
+ img = bilinear_resize(img, size)
170
+ return img > 0.5
171
+
172
+ def separate_objects(self, mask: np.ndarray, thickness: int) -> List[np.ndarray]:
173
+ """Erode-then-relabel object separation (watershed-lite), dropping
174
+ slivers whose area is under 15% of their own bounding-box area."""
175
+ radius = max(1, thickness // 10)
176
+ core = _binary_erode(mask > 0, iterations=radius)
177
+ comps = split_components(core, min_area=1)
178
+ result: List[np.ndarray] = []
179
+ for comp in comps:
180
+ grown = _binary_dilate(comp, iterations=radius) & (mask > 0)
181
+ area = int(grown.sum())
182
+ if area == 0:
183
+ continue
184
+ ys, xs = np.nonzero(grown)
185
+ bbox_area = int(ys.max() - ys.min() + 1) * int(xs.max() - xs.min() + 1)
186
+ if bbox_area * 0.15 > area:
187
+ continue
188
+ result.append(grown)
189
+ return result
190
+
191
+ def soften_by_boundary_bridge(self, mask: np.ndarray, radius: float,
192
+ iterations: int, thickness: int) -> np.ndarray:
193
+ """Soften by bridging, not by feathering: between every pair of
194
+ *different* fragments, paints a capsule stroke across their nearest
195
+ boundary points whenever that gap is within
196
+ ``min(thickness * 0.01 * radius, 51.0)`` pixels -- reuniting pieces
197
+ the earlier erosion-based separation split too eagerly, while gaps
198
+ wider than the threshold are left as genuinely separate objects."""
199
+ h, w = mask.shape
200
+ threshold = min(thickness * 0.01 * radius, 51.0)
201
+ out = (mask > 0).copy()
202
+ labels, n = label_components(out)
203
+ if n < 2:
204
+ return out
205
+ br = BrushRasterizer(w, h)
206
+ stroke_radius = max(1.0, float(iterations))
207
+ boundaries = [PolygonSelection.from_mask(labels == lab).boundary_points()
208
+ for lab in range(1, n + 1)]
209
+ for i in range(n):
210
+ pts_i = boundaries[i]
211
+ if len(pts_i) == 0:
212
+ continue
213
+ for j in range(i + 1, n):
214
+ pts_j = boundaries[j]
215
+ if len(pts_j) == 0:
216
+ continue
217
+ d = np.hypot(pts_i[:, None, 0] - pts_j[None, :, 0],
218
+ pts_i[:, None, 1] - pts_j[None, :, 1])
219
+ flat_idx = int(np.argmin(d))
220
+ a_idx, b_idx = np.unravel_index(flat_idx, d.shape)
221
+ if d[a_idx, b_idx] <= threshold:
222
+ p1, p2 = pts_i[a_idx], pts_j[b_idx]
223
+ out |= br.stamp_stroke([tuple(map(float, p1)), tuple(map(float, p2))],
224
+ radius=stroke_radius)
225
+ return out
226
+
227
+ def process_selection(self, mask: np.ndarray, radius: float = 3.0,
228
+ iterations: int = 1,
229
+ forbidden: Optional[np.ndarray] = None) -> np.ndarray:
230
+ """Full refinement pipeline: estimate thickness, denoise/smooth using
231
+ it, conditionally separate touching objects (only kept if their
232
+ per-row overlap with the smoothed selection is between 15% and 85%),
233
+ then soften by boundary bridging."""
234
+ mask_bool = mask > 0
235
+ if not mask_bool.any():
236
+ return mask_bool
237
+ thickness = self.estimate_thickness(mask_bool)
238
+ levels = 3 if thickness <= 8 else 4
239
+ smoothed = self._pyramid_smooth(mask_bool, levels)
240
+ objects = self.separate_objects(smoothed, thickness)
241
+ working = smoothed
242
+ if objects:
243
+ union = np.zeros_like(smoothed)
244
+ for comp in objects:
245
+ union |= comp
246
+ ratio = ScanlineSelection.from_mask(smoothed).row_overlap_ratio(
247
+ ScanlineSelection.from_mask(union))
248
+ if 15.0 <= ratio <= 85.0:
249
+ working = union
250
+ soft = self.soften_by_boundary_bridge(working, radius, iterations, thickness)
251
+ if forbidden is not None:
252
+ soft = self.exclude_region(soft, forbidden)
253
+ return soft
@@ -105,7 +105,7 @@ class RemoveObjectJob:
105
105
  # Smooth jagged edges, but never drop user selected pixels — eroding
106
106
  # the mask would leave slivers of the object unremoved. Union back
107
107
  # with the original selection so refinement can only grow coverage.
108
- hole = self._enh.denoise_and_smooth(hole, iterations=1) | (mask > 0)
108
+ hole = self._enh.process_selection(hole, radius=3.0, iterations=1) | (mask > 0)
109
109
  if not hole.any():
110
110
  return JobResult(self.image.copy(), tier, stages=stages)
111
111
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: object-remove
3
- Version: 0.2.1
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,7 +31,7 @@ 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/0.2.1/)
34
+ [![PyPI](https://img.shields.io/pypi/v/object-remove.svg)](https://pypi.org/project/object-remove/)
35
35
 
36
36
  A from scratch implementation of an on device object removal pipeline (see
37
37
  [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
@@ -47,6 +47,7 @@ object_remove/mask/auto_select.py
47
47
  object_remove/mask/brush_rasterizer.py
48
48
  object_remove/mask/connected_components.py
49
49
  object_remove/mask/polygon_selection.py
50
+ object_remove/mask/scanline_selection.py
50
51
  object_remove/mask/selection_enhancer.py
51
52
  object_remove/pipeline/__init__.py
52
53
  object_remove/pipeline/remove_object_job.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "object-remove"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "From-scratch reference implementation of a tiered on-device object-removal pipeline, CPU and GPU"
9
9
  readme = "README.md"
10
10
  license = "MIT"