object-remove 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- object_remove/__init__.py +37 -0
- object_remove/fillengines/__init__.py +20 -0
- object_remove/fillengines/patch_comparator.py +182 -0
- object_remove/fillengines/patchmatch/__init__.py +41 -0
- object_remove/fillengines/patchmatch/group_solver.py +127 -0
- object_remove/fillengines/patchmatch/patch.py +48 -0
- object_remove/fillengines/patchmatch/patch_scorer.py +38 -0
- object_remove/fillengines/patchmatch/patch_search_index.py +56 -0
- object_remove/fillengines/patchmatch/solver.py +152 -0
- object_remove/fillengines/self_similar_shift.py +368 -0
- object_remove/imageutil.py +59 -0
- object_remove/mask/__init__.py +22 -0
- object_remove/mask/auto_select.py +84 -0
- object_remove/mask/brush_rasterizer.py +97 -0
- object_remove/mask/connected_components.py +110 -0
- object_remove/mask/polygon_selection.py +219 -0
- object_remove/mask/selection_enhancer.py +127 -0
- object_remove/pipeline/__init__.py +7 -0
- object_remove/pipeline/remove_object_job.py +154 -0
- object_remove/py.typed +0 -0
- object_remove/pyramid/__init__.py +15 -0
- object_remove/pyramid/image_pyramid.py +101 -0
- object_remove/pyramid/scale_scheduler.py +56 -0
- object_remove/pyramid/tile_scheduler.py +80 -0
- object_remove/render/__init__.py +16 -0
- object_remove/render/compositor.py +62 -0
- object_remove/render/multiband_blender.py +75 -0
- object_remove/render/poisson_blender.py +132 -0
- object_remove/session/__init__.py +11 -0
- object_remove/session/empty_session_sweeper.py +49 -0
- object_remove/session/undo_session_manager.py +178 -0
- object_remove-0.1.0.dist-info/METADATA +130 -0
- object_remove-0.1.0.dist-info/RECORD +36 -0
- object_remove-0.1.0.dist-info/WHEEL +5 -0
- object_remove-0.1.0.dist-info/licenses/LICENSE +21 -0
- object_remove-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Poisson (gradient domain) blending — ``PoissonBlend`` / ``PoissonBlend2``.
|
|
2
|
+
|
|
3
|
+
A first class alternative to multi band blending, not a fallback. Parameter
|
|
4
|
+
surface:
|
|
5
|
+
|
|
6
|
+
=========== ========= ==================================================
|
|
7
|
+
parameter range meaning
|
|
8
|
+
=========== ========= ==================================================
|
|
9
|
+
``sigma`` 0 to 10 sigma of the edge detector (edge aware guidance)
|
|
10
|
+
``multiplier`` 0 to 10 gradient domain blend strength
|
|
11
|
+
``algo_n`` 1 or 2 selects which of two Poisson solver variants to run
|
|
12
|
+
``selection`` n/a region to blend
|
|
13
|
+
=========== ========= ==================================================
|
|
14
|
+
|
|
15
|
+
Variant 1 imports the *source* (filled) gradients; variant 2 uses *mixed*
|
|
16
|
+
gradients (whichever of source/target is stronger per edge) — the two classic
|
|
17
|
+
Poisson cloning modes, mapped onto ``algo_n``. ``sigma`` drives an edge detector
|
|
18
|
+
that attenuates guidance across strong target edges; ``multiplier`` scales the
|
|
19
|
+
imported gradient.
|
|
20
|
+
|
|
21
|
+
Solves the sparse Poisson system ``L f = div(g)`` with Dirichlet boundary equal
|
|
22
|
+
to the background, per channel, via ``scipy.sparse``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import scipy.sparse as sp
|
|
29
|
+
import scipy.sparse.linalg as spla
|
|
30
|
+
|
|
31
|
+
from ..pyramid.image_pyramid import gaussian_blur
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PoissonBlend:
|
|
35
|
+
def __init__(self, sigma: float = 1.0, multiplier: float = 1.0, algo_n: int = 1):
|
|
36
|
+
self.sigma = float(np.clip(sigma, 0.0, 10.0))
|
|
37
|
+
self.multiplier = float(np.clip(multiplier, 0.0, 10.0))
|
|
38
|
+
self.algo_n = 2 if int(algo_n) == 2 else 1
|
|
39
|
+
|
|
40
|
+
def blend(self, foreground: np.ndarray, background: np.ndarray,
|
|
41
|
+
mask: np.ndarray) -> np.ndarray:
|
|
42
|
+
"""Seamlessly clone ``foreground`` into ``background`` over ``mask``."""
|
|
43
|
+
fg = foreground.astype(np.float64)
|
|
44
|
+
bg = background.astype(np.float64)
|
|
45
|
+
region = np.ascontiguousarray(mask > 0)
|
|
46
|
+
H, W = region.shape
|
|
47
|
+
if not region.any():
|
|
48
|
+
return bg.copy()
|
|
49
|
+
|
|
50
|
+
idx = -np.ones((H, W), dtype=np.int64)
|
|
51
|
+
ys, xs = np.nonzero(region)
|
|
52
|
+
idx[ys, xs] = np.arange(len(ys))
|
|
53
|
+
N = len(ys)
|
|
54
|
+
|
|
55
|
+
edge_w = self._edge_weights(bg)
|
|
56
|
+
|
|
57
|
+
# sparse Laplacian: 4 on diagonal, -1 to in region neighbours
|
|
58
|
+
neighbours = ((-1, 0), (1, 0), (0, -1), (0, 1))
|
|
59
|
+
rows = [np.arange(N)]
|
|
60
|
+
cols = [np.arange(N)]
|
|
61
|
+
data = [np.full(N, 4.0)]
|
|
62
|
+
for dy, dx in neighbours:
|
|
63
|
+
ny, nx = ys + dy, xs + dx
|
|
64
|
+
inb = (ny >= 0) & (ny < H) & (nx >= 0) & (nx < W)
|
|
65
|
+
nin = np.zeros(N, dtype=bool)
|
|
66
|
+
nin[inb] = region[ny[inb], nx[inb]]
|
|
67
|
+
src = np.nonzero(nin)[0]
|
|
68
|
+
rows.append(src)
|
|
69
|
+
cols.append(idx[ny[src], nx[src]])
|
|
70
|
+
data.append(np.full(len(src), -1.0))
|
|
71
|
+
A = sp.csr_matrix((np.concatenate(data),
|
|
72
|
+
(np.concatenate(rows), np.concatenate(cols))),
|
|
73
|
+
shape=(N, N))
|
|
74
|
+
lu = spla.splu(sp.csc_matrix(A))
|
|
75
|
+
|
|
76
|
+
out = bg.copy()
|
|
77
|
+
for c in range(fg.shape[2]):
|
|
78
|
+
b = self._rhs(fg[:, :, c], bg[:, :, c], region, idx, ys, xs,
|
|
79
|
+
neighbours, edge_w)
|
|
80
|
+
f = lu.solve(b)
|
|
81
|
+
out[ys, xs, c] = np.clip(f, 0.0, 1.0)
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
def _edge_weights(self, bg: np.ndarray) -> np.ndarray:
|
|
85
|
+
"""1 in flat areas, dropping to 0 across strong target edges (controlled by sigma)."""
|
|
86
|
+
if self.sigma <= 0:
|
|
87
|
+
return np.ones(bg.shape[:2])
|
|
88
|
+
gray = bg.mean(axis=2)
|
|
89
|
+
sm = gray
|
|
90
|
+
for _ in range(int(round(self.sigma))):
|
|
91
|
+
sm = gaussian_blur(sm)
|
|
92
|
+
gy, gx = np.gradient(sm)
|
|
93
|
+
mag = np.hypot(gx, gy)
|
|
94
|
+
scale = mag.mean() + 1e-6
|
|
95
|
+
return np.exp(-mag / (scale * (1.0 + self.sigma)))
|
|
96
|
+
|
|
97
|
+
def _rhs(self, fg_c, bg_c, region, idx, ys, xs, neighbours, edge_w):
|
|
98
|
+
H, W = region.shape
|
|
99
|
+
N = len(ys)
|
|
100
|
+
b = np.zeros(N)
|
|
101
|
+
for dy, dx in neighbours:
|
|
102
|
+
ny, nx = ys + dy, xs + dx
|
|
103
|
+
inb = (ny >= 0) & (ny < H) & (nx >= 0) & (nx < W)
|
|
104
|
+
# guidance gradient across this edge
|
|
105
|
+
grad_src = np.zeros(N)
|
|
106
|
+
grad_dst = np.zeros(N)
|
|
107
|
+
grad_src[inb] = fg_c[ys[inb], xs[inb]] - fg_c[ny[inb], nx[inb]]
|
|
108
|
+
grad_dst[inb] = bg_c[ys[inb], xs[inb]] - bg_c[ny[inb], nx[inb]]
|
|
109
|
+
if self.algo_n == 2:
|
|
110
|
+
# mixed gradients: keep whichever is steeper (variant 2)
|
|
111
|
+
use_dst = np.abs(grad_dst) > np.abs(grad_src)
|
|
112
|
+
edge_gradient = np.where(use_dst, grad_dst, grad_src)
|
|
113
|
+
else:
|
|
114
|
+
edge_gradient = grad_src
|
|
115
|
+
edge_gradient = edge_gradient * self.multiplier
|
|
116
|
+
# edge aware attenuation from the target
|
|
117
|
+
w = np.ones(N)
|
|
118
|
+
w[inb] = edge_w[ys[inb], xs[inb]]
|
|
119
|
+
edge_gradient = edge_gradient * w
|
|
120
|
+
b += edge_gradient
|
|
121
|
+
# Dirichlet: neighbours outside the region contribute background value
|
|
122
|
+
nin = np.zeros(N, dtype=bool)
|
|
123
|
+
nin[inb] = region[ny[inb], nx[inb]]
|
|
124
|
+
boundary = inb & ~nin
|
|
125
|
+
b[boundary] += bg_c[ny[boundary], nx[boundary]]
|
|
126
|
+
return b
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ``PoissonBlend2`` — the tier 3 default (algo_n=2, mixed gradients).
|
|
130
|
+
class PoissonBlend2(PoissonBlend):
|
|
131
|
+
def __init__(self, sigma: float = 1.0, multiplier: float = 1.0):
|
|
132
|
+
super().__init__(sigma=sigma, multiplier=multiplier, algo_n=2)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Stage F — session / undo persistence."""
|
|
2
|
+
|
|
3
|
+
from .undo_session_manager import (
|
|
4
|
+
SessionManager, EditSession, EditStep, rle_encode, rle_decode,
|
|
5
|
+
)
|
|
6
|
+
from .empty_session_sweeper import EmptySessionSweeper
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SessionManager", "EditSession", "EditStep",
|
|
10
|
+
"rle_encode", "rle_decode", "EmptySessionSweeper",
|
|
11
|
+
]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Orphaned empty session GC.
|
|
2
|
+
|
|
3
|
+
A startup/periodic sweep, distinct from the explicit per session cleanup path,
|
|
4
|
+
that removes session directories left behind by aborted or crashed edits (empty
|
|
5
|
+
or with no valid recorded step). Deletions go through the same queued/locked
|
|
6
|
+
delete task as explicit removal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import List
|
|
14
|
+
|
|
15
|
+
from .undo_session_manager import SessionManager, _RemoveTask
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmptySessionSweeper:
|
|
19
|
+
def __init__(self, manager: SessionManager):
|
|
20
|
+
self.manager = manager
|
|
21
|
+
|
|
22
|
+
def sweep(self) -> List[str]:
|
|
23
|
+
"""Queue removal of empty/orphaned session dirs; return the ids swept."""
|
|
24
|
+
root = self.manager.root
|
|
25
|
+
swept: List[str] = []
|
|
26
|
+
if not os.path.isdir(root):
|
|
27
|
+
return swept
|
|
28
|
+
for name in os.listdir(root):
|
|
29
|
+
path = os.path.join(root, name)
|
|
30
|
+
if not os.path.isdir(path):
|
|
31
|
+
continue
|
|
32
|
+
if self._is_orphan(path):
|
|
33
|
+
self.manager._queue.submit(
|
|
34
|
+
_RemoveTask("remove_session_directory", path))
|
|
35
|
+
swept.append(name)
|
|
36
|
+
return swept
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def _is_orphan(path: str) -> bool:
|
|
40
|
+
manifest = os.path.join(path, "manifest.json")
|
|
41
|
+
if not os.path.isfile(manifest):
|
|
42
|
+
# a directory with no manifest at all is an aborted/crashed edit
|
|
43
|
+
return True
|
|
44
|
+
try:
|
|
45
|
+
with open(manifest) as fh:
|
|
46
|
+
data = json.load(fh)
|
|
47
|
+
except Exception:
|
|
48
|
+
return True # corrupt, so discard
|
|
49
|
+
return len(data.get("steps", [])) == 0
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Session / undo persistence.
|
|
2
|
+
|
|
3
|
+
* each edit gets a session id and a directory holding the mask history
|
|
4
|
+
(RLE encoded) and step metadata;
|
|
5
|
+
* session directory removal is **not** an inline synchronous delete — it is
|
|
6
|
+
dispatched as a task onto a queue and run under an explicit lock, so the UI
|
|
7
|
+
thread never blocks on filesystem I/O and two removals can't race;
|
|
8
|
+
* corrupt sessions are validated and discarded rather than trusted.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import queue
|
|
16
|
+
import shutil
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# RLE mask codec (compact mask history)
|
|
27
|
+
def rle_encode(mask: np.ndarray) -> dict:
|
|
28
|
+
flat = np.ascontiguousarray(mask > 0).ravel()
|
|
29
|
+
if flat.size == 0:
|
|
30
|
+
return {"h": 0, "w": 0, "runs": []}
|
|
31
|
+
changes = np.flatnonzero(np.diff(flat)) + 1
|
|
32
|
+
bounds = np.concatenate(([0], changes, [flat.size]))
|
|
33
|
+
runs = np.diff(bounds).tolist()
|
|
34
|
+
return {"h": int(mask.shape[0]), "w": int(mask.shape[1]),
|
|
35
|
+
"first": int(flat[0]), "runs": runs}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def rle_decode(obj: dict) -> np.ndarray:
|
|
39
|
+
h, w = obj["h"], obj["w"]
|
|
40
|
+
flat = np.zeros(h * w, dtype=bool)
|
|
41
|
+
val = bool(obj.get("first", 0))
|
|
42
|
+
pos = 0
|
|
43
|
+
for run in obj["runs"]:
|
|
44
|
+
if val:
|
|
45
|
+
flat[pos:pos + run] = True
|
|
46
|
+
pos += run
|
|
47
|
+
val = not val
|
|
48
|
+
return flat.reshape(h, w)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# background delete task queue
|
|
52
|
+
@dataclass
|
|
53
|
+
class _RemoveTask:
|
|
54
|
+
kind: str # "remove_session_directory"
|
|
55
|
+
path: str
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _TaskQueue:
|
|
59
|
+
"""Serialises removals under a lock on a background worker thread."""
|
|
60
|
+
|
|
61
|
+
def __init__(self):
|
|
62
|
+
self._q: "queue.Queue[Optional[_RemoveTask]]" = queue.Queue()
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
65
|
+
self._thread.start()
|
|
66
|
+
|
|
67
|
+
def submit(self, task: _RemoveTask) -> None:
|
|
68
|
+
self._q.put(task)
|
|
69
|
+
|
|
70
|
+
def _run(self) -> None:
|
|
71
|
+
while True:
|
|
72
|
+
task = self._q.get()
|
|
73
|
+
if task is None:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
with self._lock:
|
|
77
|
+
if task.kind == "remove_session_directory" and os.path.isdir(task.path):
|
|
78
|
+
shutil.rmtree(task.path, ignore_errors=True)
|
|
79
|
+
finally:
|
|
80
|
+
self._q.task_done()
|
|
81
|
+
|
|
82
|
+
def flush(self) -> None:
|
|
83
|
+
self._q.join()
|
|
84
|
+
|
|
85
|
+
def stop(self) -> None:
|
|
86
|
+
self._q.put(None)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class EditStep:
|
|
91
|
+
index: int
|
|
92
|
+
mask_rle: dict
|
|
93
|
+
note: str = ""
|
|
94
|
+
ts: float = field(default_factory=time.time)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class SessionManager:
|
|
98
|
+
"""One instance per app; owns the sessions root and the delete queue."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, root: str):
|
|
101
|
+
self.root = root
|
|
102
|
+
os.makedirs(root, exist_ok=True)
|
|
103
|
+
self._queue = _TaskQueue()
|
|
104
|
+
self._sessions: dict[str, "EditSession"] = {}
|
|
105
|
+
|
|
106
|
+
def new_session(self) -> "EditSession":
|
|
107
|
+
session_id = uuid.uuid4().hex[:16]
|
|
108
|
+
path = os.path.join(self.root, session_id)
|
|
109
|
+
os.makedirs(path, exist_ok=True)
|
|
110
|
+
sess = EditSession(session_id, path)
|
|
111
|
+
self._sessions[session_id] = sess
|
|
112
|
+
return sess
|
|
113
|
+
|
|
114
|
+
# explicit per session cleanup (queued/locked, not inline)
|
|
115
|
+
def remove_session_directory(self, session: "EditSession") -> None:
|
|
116
|
+
"""Queue removal of a specific (committed/exported) session."""
|
|
117
|
+
self._sessions.pop(session.session_id, None)
|
|
118
|
+
self._queue.submit(_RemoveTask("remove_session_directory", session.path))
|
|
119
|
+
|
|
120
|
+
def flush(self) -> None:
|
|
121
|
+
self._queue.flush()
|
|
122
|
+
|
|
123
|
+
def close(self) -> None:
|
|
124
|
+
self._queue.flush()
|
|
125
|
+
self._queue.stop()
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def validate_session(path: str) -> bool:
|
|
129
|
+
"""A session is valid iff its manifest parses and its steps decode."""
|
|
130
|
+
manifest = os.path.join(path, "manifest.json")
|
|
131
|
+
if not os.path.isfile(manifest):
|
|
132
|
+
return False
|
|
133
|
+
try:
|
|
134
|
+
with open(manifest) as fh:
|
|
135
|
+
data = json.load(fh)
|
|
136
|
+
for step in data.get("steps", []):
|
|
137
|
+
rle_decode(step["mask_rle"])
|
|
138
|
+
return True
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class EditSession:
|
|
144
|
+
def __init__(self, session_id: str, path: str):
|
|
145
|
+
self.session_id = session_id
|
|
146
|
+
self.path = path
|
|
147
|
+
self.steps: List[EditStep] = []
|
|
148
|
+
|
|
149
|
+
def record(self, mask: np.ndarray, note: str = "") -> EditStep:
|
|
150
|
+
step = EditStep(len(self.steps), rle_encode(mask), note)
|
|
151
|
+
self.steps.append(step)
|
|
152
|
+
self._persist()
|
|
153
|
+
return step
|
|
154
|
+
|
|
155
|
+
def undo(self) -> Optional[EditStep]:
|
|
156
|
+
if not self.steps:
|
|
157
|
+
return None
|
|
158
|
+
step = self.steps.pop()
|
|
159
|
+
self._persist()
|
|
160
|
+
return step
|
|
161
|
+
|
|
162
|
+
def mask_at(self, index: int) -> np.ndarray:
|
|
163
|
+
return rle_decode(self.steps[index].mask_rle)
|
|
164
|
+
|
|
165
|
+
def _persist(self) -> None:
|
|
166
|
+
manifest = {
|
|
167
|
+
"session_id": self.session_id,
|
|
168
|
+
"steps": [{"index": s.index, "mask_rle": s.mask_rle,
|
|
169
|
+
"note": s.note, "ts": s.ts} for s in self.steps],
|
|
170
|
+
}
|
|
171
|
+
tmp_manifest_path = os.path.join(self.path, "manifest.json.tmp")
|
|
172
|
+
with open(tmp_manifest_path, "w") as fh:
|
|
173
|
+
json.dump(manifest, fh)
|
|
174
|
+
# atomic rename so a crash mid write never leaves a truncated manifest
|
|
175
|
+
os.replace(tmp_manifest_path, os.path.join(self.path, "manifest.json"))
|
|
176
|
+
|
|
177
|
+
def is_empty(self) -> bool:
|
|
178
|
+
return len(self.steps) == 0
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: object_remove
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: From-scratch CPU reference implementation of a tiered on-device object-removal pipeline
|
|
5
|
+
Author: shalaga44
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shalaga44/object_remove
|
|
8
|
+
Project-URL: Issues, https://github.com/shalaga44/object_remove/issues
|
|
9
|
+
Keywords: image-processing,inpainting,object-removal,computer-vision
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Operating System :: OS Independent
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=2.0
|
|
23
|
+
Requires-Dist: scipy>=1.10
|
|
24
|
+
Requires-Dist: Pillow>=9.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# object_remove
|
|
30
|
+
|
|
31
|
+
A from scratch, CPU implementation of an on device object removal pipeline (see
|
|
32
|
+
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the full design): a
|
|
33
|
+
polygon/scanline mask layer, **three fill engines** of increasing
|
|
34
|
+
sophistication, **two interchangeable blend backends**, and a locked/queued
|
|
35
|
+
session persistence layer.
|
|
36
|
+
|
|
37
|
+
Everything here is implemented from primitives (FAST corners, BRIEF descriptors,
|
|
38
|
+
Hamming matching, PatchMatch, Laplacian/Poisson blending) — no OpenCV, no learned
|
|
39
|
+
weights.
|
|
40
|
+
|
|
41
|
+
## Quick start
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -r requirements.txt # numpy, scipy, Pillow
|
|
45
|
+
PYTHONPATH=. python examples/demo.py # writes before/after PNGs to examples/out/
|
|
46
|
+
PYTHONPATH=. python -m pytest -q # 31 tests
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import numpy as np
|
|
51
|
+
from object_remove import RemoveObjectJob, EngineTier
|
|
52
|
+
|
|
53
|
+
result = RemoveObjectJob(image_rgb).run(mask, tier=EngineTier.AUTO,
|
|
54
|
+
progress=lambda stage, frac: ...)
|
|
55
|
+
removed = result.image # float32 RGB in [0, 1]
|
|
56
|
+
print(result.tier_used) # which engine AUTO picked
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`image_rgb` is any `(H, W, 3)` array (uint8 or float); `mask` is `(H, W)` with
|
|
60
|
+
nonzero marking the object to remove.
|
|
61
|
+
|
|
62
|
+
## Module layout
|
|
63
|
+
|
|
64
|
+
| Stage | Concern | This repo |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| A — mask | selection, brush, auto select | `object_remove/mask/` |
|
|
67
|
+
| B/D — scale/tiles | pyramid + tile scheduling | `object_remove/pyramid/` |
|
|
68
|
+
| C1 — legacy CPU fill | segment aware patch comparator | `fillengines/patch_comparator.py` |
|
|
69
|
+
| C2 — texture synthesis | self similar rigid shift | `fillengines/self_similar_shift.py` |
|
|
70
|
+
| C3 — patch match | multi scale PatchMatch | `fillengines/patchmatch/` |
|
|
71
|
+
| E — blend | multi band + Poisson | `object_remove/render/` |
|
|
72
|
+
| F — session | undo / persistence | `object_remove/session/` |
|
|
73
|
+
| — orchestration | tier selection, progress, cancellation | `pipeline/remove_object_job.py` |
|
|
74
|
+
|
|
75
|
+
## The three fill engines
|
|
76
|
+
|
|
77
|
+
- **Tier 1 — `SegmentAwarePatchFillEngine`** — greedy onion peel exemplar fill
|
|
78
|
+
scored by segment aware SSD with byte mask validity. Cheap, correct on small
|
|
79
|
+
holes; the low end / tiny hole fallback.
|
|
80
|
+
- **Tier 2 — `SelfSimilarShiftEngine`** — the fast default for repetitive texture.
|
|
81
|
+
FAST corners + 256 bit BRIEF + Hamming matching find a *rigid self similar
|
|
82
|
+
offset*; a 1D DP seam cuts the copied block in cleanly, run for x then y via
|
|
83
|
+
a transpose trick.
|
|
84
|
+
- **Tier 3 — `PatchMatchEngine`** — multi scale, multi group PatchMatch inpainting
|
|
85
|
+
(random init → propagation → random search, then patch voting reconstruction,
|
|
86
|
+
coarse to fine). The quality path.
|
|
87
|
+
|
|
88
|
+
`EngineTier.AUTO` sends truly tiny holes to tier 1, otherwise tries the cheap
|
|
89
|
+
tier 2 rigid shift first and falls back to tier 3 PatchMatch when the shift's
|
|
90
|
+
boundary continuity score is poor.
|
|
91
|
+
|
|
92
|
+
## The two blend backends
|
|
93
|
+
|
|
94
|
+
- **`MultiBandBlender`** — Laplacian pyramid blend with a fixed **4 tap
|
|
95
|
+
kernel**, `[0.15439, 0.15133, 0.14252, 0.12896]`, and the mask feathered
|
|
96
|
+
*per pyramid level*.
|
|
97
|
+
- **`PoissonBlend` / `PoissonBlend2`** — gradient domain blend (`sigma`,
|
|
98
|
+
`multiplier`, `algo_n` 1|2 variant), solved as a sparse Poisson system.
|
|
99
|
+
Poisson is the safe default for single image inpainting because it reads
|
|
100
|
+
only the (valid) hole boundary; the compositor repairs the background under
|
|
101
|
+
the hole so no backend ever samples the object being removed.
|
|
102
|
+
|
|
103
|
+
## Session persistence
|
|
104
|
+
|
|
105
|
+
`SessionManager` gives each edit a session id and an RLE mask history. Directory
|
|
106
|
+
removal is dispatched onto a background queue and run **under a lock** — never
|
|
107
|
+
an inline delete from the caller. A separate `EmptySessionSweeper` GCs orphaned
|
|
108
|
+
empty session dirs left by aborted/crashed edits.
|
|
109
|
+
|
|
110
|
+
## Status & scope
|
|
111
|
+
|
|
112
|
+
Implemented and tested: the full CPU pipeline across all stages. Auto select
|
|
113
|
+
is explicitly an *interface + CPU fallback*: a real segmentation model can be
|
|
114
|
+
dropped in behind `AutoSelectModel`, which ships a working color flood
|
|
115
|
+
fallback so tap to select runs end to end without one.
|
|
116
|
+
|
|
117
|
+
Engines run in pure Python/numpy for clarity; they are correct, not tuned for
|
|
118
|
+
speed. The hot inner loops (PatchMatch sweep, Poisson solve) are the obvious
|
|
119
|
+
targets for a native/GPU port.
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
object_remove/
|
|
123
|
+
mask/ brush_rasterizer, polygon_selection, selection_enhancer,
|
|
124
|
+
connected_components, auto_select
|
|
125
|
+
pyramid/ image_pyramid, scale_scheduler, tile_scheduler
|
|
126
|
+
fillengines/ patch_comparator (T1), self_similar_shift (T2), patchmatch/ (T3)
|
|
127
|
+
render/ multiband_blender, poisson_blender, compositor
|
|
128
|
+
session/ undo_session_manager, empty_session_sweeper
|
|
129
|
+
pipeline/ remove_object_job
|
|
130
|
+
```
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
object_remove/__init__.py,sha256=3NybrvSQ27HDkS4oNTSWCbF_d4mISopZczUV1Fo5oYo,1385
|
|
2
|
+
object_remove/imageutil.py,sha256=dytMnESHndVBjBuiWbyRM3ms7Q-eFfLJfynhIQR5GVM,1828
|
|
3
|
+
object_remove/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
object_remove/fillengines/__init__.py,sha256=h6U2VtdJ8axVIPglhWp9KtVe68BBJAPX-oNBHJeWxd0,754
|
|
5
|
+
object_remove/fillengines/patch_comparator.py,sha256=2OYau1BVwp-PMkhaNZrQQdkFtM5MYTOiuGWuq-3aThM,7193
|
|
6
|
+
object_remove/fillengines/self_similar_shift.py,sha256=VwCRW2ru5bJXx_hdO1f9kmFvweBmL1KHixZTD6GeVK8,14272
|
|
7
|
+
object_remove/fillengines/patchmatch/__init__.py,sha256=GQZNoALA6L_7rJCz2d576cmzVB9_WoQknGtCvlGK1dg,1644
|
|
8
|
+
object_remove/fillengines/patchmatch/group_solver.py,sha256=3SlSaqzv1IGZ7jokWomqGq9i0nGM40wAxcNHt-9CKrs,4930
|
|
9
|
+
object_remove/fillengines/patchmatch/patch.py,sha256=2zMCOoIcJuq_a-rWuznWsB0uy2pxYFMzzXmgHmckRXQ,1371
|
|
10
|
+
object_remove/fillengines/patchmatch/patch_scorer.py,sha256=RgZHnQ2sR5aW-Gw4iBqSgSp25Ib3W8v6IF-ijNtZJZc,1455
|
|
11
|
+
object_remove/fillengines/patchmatch/patch_search_index.py,sha256=_43IQEVHD5OK0l1hI5cnrJDylyBAZztFsotTZt2mmvk,2178
|
|
12
|
+
object_remove/fillengines/patchmatch/solver.py,sha256=WcK_ikt6a3r6JrxkY5xaNtg4KW3TwFLCtGbXv0auH2Y,6419
|
|
13
|
+
object_remove/mask/__init__.py,sha256=bQVvd1UM5kd76fnooH2z4a_P9T7_NJI3_J6DNT3rXgE,733
|
|
14
|
+
object_remove/mask/auto_select.py,sha256=KZ6NdwcCqXitI7GD0Ub2Oy3_Q-li0la2Bq2rV0Z1PJ0,3267
|
|
15
|
+
object_remove/mask/brush_rasterizer.py,sha256=R0C48hFWl444qVd-fw8FrBxnyuoAfFAZfKCFOMfHaz4,3861
|
|
16
|
+
object_remove/mask/connected_components.py,sha256=4m4nXnlfxeDTjtn8SgemF9LH5e7uOgHlE44VLReGI4U,3521
|
|
17
|
+
object_remove/mask/polygon_selection.py,sha256=qOMl14O1oNTU9arersIUBFe_fZBcEB4dcD7tTErMuSg,7868
|
|
18
|
+
object_remove/mask/selection_enhancer.py,sha256=uX5IXw34ham9SawOxDcwWVKcxFd6o1UjoT8iAg696to,4787
|
|
19
|
+
object_remove/pipeline/__init__.py,sha256=qeSxaz5HzpyP4moqd7Rcx3acW5W7V3OoUNiaY4siHqs,216
|
|
20
|
+
object_remove/pipeline/remove_object_job.py,sha256=THvnUwEHv5X4vUv7pdqsj_ZuC0IKB6BL0gcWBXgTFro,6344
|
|
21
|
+
object_remove/pyramid/__init__.py,sha256=zw5zsE1A1xJjChM07snD8XpZxQjI6m-isQQfwQRnsVA,525
|
|
22
|
+
object_remove/pyramid/image_pyramid.py,sha256=iLxDZGN6qEvbh9-u3nAEK_RUJBBo1HBzOmd6LNSCWCQ,3686
|
|
23
|
+
object_remove/pyramid/scale_scheduler.py,sha256=WIVymIiku1UVGLJ8bZhiTwbjU5lA8RIbEZvls_xXTDE,2052
|
|
24
|
+
object_remove/pyramid/tile_scheduler.py,sha256=xpvK19y_JSaxRwkMcNicKao1dZ0flfyvcMS_n3rXcfs,2954
|
|
25
|
+
object_remove/render/__init__.py,sha256=3821iqB146nXdeUtLz5xhvDivEkPmO-rSdOecvERtTU,523
|
|
26
|
+
object_remove/render/compositor.py,sha256=Wuma1RJ4YK9yEo6KHNThAeWHCLDjlZMzXrFkl1Mf6lc,2592
|
|
27
|
+
object_remove/render/multiband_blender.py,sha256=bmHB5ul5V2BoUEaKHgudXcS6_cN8Ht_y6kryfVJQM0E,2887
|
|
28
|
+
object_remove/render/poisson_blender.py,sha256=0ebhm0e1wtrayybTQesFEjVzEBA1dvwvfd4GW8dC2q0,5434
|
|
29
|
+
object_remove/session/__init__.py,sha256=54sYX7WqDWlEfq8WY-yUOboiKChNa2wzflwbmJeDXE8,326
|
|
30
|
+
object_remove/session/empty_session_sweeper.py,sha256=QKoCzs5svKM6kK8F7GsBTR4OGB_lHbAAskpm_O7WHO4,1616
|
|
31
|
+
object_remove/session/undo_session_manager.py,sha256=c2f9xC9h5596EWX2BCzYmEuSf61IV6tkHrqMggLVVGM,5473
|
|
32
|
+
object_remove-0.1.0.dist-info/licenses/LICENSE,sha256=MXtYKLz9evYRq3bIl3mXLvBOjGbGLe-O9ofEH5SFQlY,1066
|
|
33
|
+
object_remove-0.1.0.dist-info/METADATA,sha256=tDpw3g7BghCEdZUHFWDMMN30egmDCUkTxQm5z79Ob3U,5726
|
|
34
|
+
object_remove-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
35
|
+
object_remove-0.1.0.dist-info/top_level.txt,sha256=qaadAwnEePg4QvC7T59W-ucRB7lXHekDw3J-nET_aPk,14
|
|
36
|
+
object_remove-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shalaga44
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
object_remove
|