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.
- {object_remove-0.2.1 → object_remove-0.2.2}/PKG-INFO +2 -2
- {object_remove-0.2.1 → object_remove-0.2.2}/README.md +1 -1
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/__init__.py +1 -1
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/perspective_correction.py +19 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver_gpu.py +35 -0
- object_remove-0.2.2/object_remove/mask/connected_components.py +142 -0
- object_remove-0.2.2/object_remove/mask/scanline_selection.py +123 -0
- object_remove-0.2.2/object_remove/mask/selection_enhancer.py +253 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pipeline/remove_object_job.py +1 -1
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/PKG-INFO +2 -2
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/SOURCES.txt +1 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/pyproject.toml +1 -1
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_gpu.py +0 -11
- object_remove-0.2.2/tests/test_mask.py +131 -0
- object_remove-0.2.1/object_remove/mask/connected_components.py +0 -110
- object_remove-0.2.1/object_remove/mask/selection_enhancer.py +0 -127
- object_remove-0.2.1/tests/test_mask.py +0 -70
- {object_remove-0.2.1 → object_remove-0.2.2}/LICENSE +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/box_filter.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/canny.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/distance_transform.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/frequency_separation.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/harris.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/max_finder.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/nearest_opaque.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/orb.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/cv/symmetric_convolution.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/effects/background_blur.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/clone_stamp.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patch_comparator_gpu.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/group_solver.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_scorer.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/patch_search_index.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/solver.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/patchmatch/torch_solver.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/patch_grid_solver.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/quad_tree_index.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/renderer.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/primary/scoring.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/self_similar_shift_gpu.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/fillengines/wire_removal.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/imageutil.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/auto_select.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/brush_rasterizer.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/mask/polygon_selection.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pipeline/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/py.typed +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/image_pyramid.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/scale_scheduler.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/tile_scheduler.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/pyramid/torch_pyramid.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/compositor.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/fused_patch_blend.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/multiband_blender.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/multiband_blender_gpu.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/poisson_blender.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/render/poisson_blender_gpu.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/runtime/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/runtime/device.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/__init__.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/empty_session_sweeper.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove/session/undo_session_manager.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/dependency_links.txt +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/requires.txt +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/object_remove.egg-info/top_level.txt +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/setup.cfg +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_clone_stamp.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_cv.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_effects.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_fillengines.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_perspective_correction.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_pipeline.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_primary_fill_engine.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_pyramid.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_render.py +0 -0
- {object_remove-0.2.1 → object_remove-0.2.2}/tests/test_session.py +0 -0
- {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.
|
|
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
|
-
[](https://pypi.org/project/object-remove/
|
|
34
|
+
[](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
|
-
[](https://pypi.org/project/object-remove/
|
|
3
|
+
[](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.
|
|
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.
|
|
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.
|
|
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
|
-
[](https://pypi.org/project/object-remove/
|
|
34
|
+
[](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.
|
|
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"
|