Skip to content

Commit ba0db6e

Browse files
authored
Merge pull request #8 from CSSFrancis/MaskCreation
Mask creation
2 parents 90daec6 + a6b719e commit ba0db6e

7 files changed

Lines changed: 586 additions & 166 deletions

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
"""
2+
Interactive Contrast Segmentation
3+
===================================
4+
5+
Click on any region of the image to flood-fill all pixels of similar
6+
intensity — the union of all seeded regions is shown as a live
7+
semi-transparent overlay on the original image.
8+
9+
**Interaction**
10+
11+
+-----------------------------------+-----------------------------------------+
12+
| Action | Effect |
13+
+===================================+=========================================+
14+
| **Left-click** | Add a *positive* seed (green dot). |
15+
| | Flood-fill grows from that pixel. |
16+
+-----------------------------------+-----------------------------------------+
17+
| **Shift + left-click** | Add a *negative* seed (red dot). |
18+
| | Subtracts that connected region from |
19+
| | the current mask. |
20+
+-----------------------------------+-----------------------------------------+
21+
| **Hover + Delete / Backspace** | Remove the nearest seed within |
22+
| | 12 image-px of the cursor. |
23+
+-----------------------------------+-----------------------------------------+
24+
| **+** / **=** | Increase tolerance (grow regions). |
25+
+-----------------------------------+-----------------------------------------+
26+
| **-** | Decrease tolerance (shrink regions). |
27+
+-----------------------------------+-----------------------------------------+
28+
| **c** (while focused) | Clear all seeds and reset mask. |
29+
+-----------------------------------+-----------------------------------------+
30+
31+
The current boolean mask numpy array is always accessible as ``mask``.
32+
33+
.. note::
34+
Move the cursor over the plot so it receives keyboard focus before
35+
pressing keys. The tolerance is shown in the plot title.
36+
"""
37+
38+
import numpy as np
39+
import anyplotlib as vw
40+
41+
# ── Synthetic multi-region image ──────────────────────────────────────────────
42+
# Five Gaussian blobs at different intensity levels on a smooth background,
43+
# plus mild Poisson-like noise — gives interesting connected regions to segment.
44+
45+
N = 256
46+
rng = np.random.default_rng(7)
47+
48+
xx, yy = np.meshgrid(np.arange(N), np.arange(N))
49+
50+
def _gauss(cx, cy, sigma, amplitude):
51+
return amplitude * np.exp(-((xx - cx)**2 + (yy - cy)**2) / (2 * sigma**2))
52+
53+
image = (
54+
_gauss( 64, 72, 28, 0.85) # bright top-left blob
55+
+ _gauss(190, 60, 22, 0.70) # mid top-right blob
56+
+ _gauss(128, 128, 40, 0.55) # dim centre blob (large)
57+
+ _gauss( 55, 195, 20, 0.90) # bright bottom-left blob
58+
+ _gauss(200, 185, 30, 0.60) # mid bottom-right blob
59+
+ 0.08 * rng.standard_normal((N, N)) # noise
60+
)
61+
# Normalise to [0, 1]
62+
image = (image - image.min()) / (image.max() - image.min())
63+
64+
# ── Segmentation: pure-numpy BFS flood-fill ───────────────────────────────────
65+
66+
def _bfs_region(img, row: int, col: int, tol: float) -> np.ndarray:
67+
"""Return a boolean mask for the connected region reachable from (row, col).
68+
69+
Connectivity is 4-connected. A neighbour is accepted when
70+
``|img[neighbour] - centre_value| <= tol``, where *centre_value* is the
71+
intensity of the seed pixel (fixed, not growing).
72+
"""
73+
H, W = img.shape
74+
seed_val = img[row, col]
75+
visited = np.zeros((H, W), dtype=bool)
76+
visited[row, col] = True
77+
stack = [(row, col)]
78+
while stack:
79+
r, c = stack.pop()
80+
for dr, dc in ((-1, 0), (1, 0), (0, -1), (0, 1)):
81+
nr, nc = r + dr, c + dc
82+
if 0 <= nr < H and 0 <= nc < W and not visited[nr, nc]:
83+
if abs(float(img[nr, nc]) - float(seed_val)) <= tol:
84+
visited[nr, nc] = True
85+
stack.append((nr, nc))
86+
return visited
87+
88+
89+
def _compute_mask(img, pos_seeds, neg_seeds, tol):
90+
"""Union of positive-seed BFS regions minus any negative-seed regions."""
91+
if not pos_seeds:
92+
return np.zeros(img.shape, dtype=bool)
93+
combined = np.zeros(img.shape, dtype=bool)
94+
for r, c in pos_seeds:
95+
combined |= _bfs_region(img, r, c, tol)
96+
for r, c in neg_seeds:
97+
combined &= ~_bfs_region(img, r, c, tol)
98+
return combined
99+
100+
101+
# ── State ─────────────────────────────────────────────────────────────────────
102+
103+
pos_seeds: list[tuple[int, int]] = [] # (row, col)
104+
neg_seeds: list[tuple[int, int]] = [] # (row, col)
105+
tolerance: float = 0.08
106+
mask = np.zeros((N, N), dtype=bool) # exposed numpy array
107+
108+
TOL_STEP = 0.01
109+
TOL_MIN = 0.005
110+
TOL_MAX = 0.40
111+
SEED_RADIUS_PIXELS = 5 # marker radius for seed dots
112+
113+
# ── Figure ────────────────────────────────────────────────────────────────────
114+
115+
fig, ax = vw.subplots(figsize=(520, 520),
116+
help="Left-click → add positive seed (grow mask)\n"
117+
"Shift + Left-click → add negative seed (shrink mask)\n"
118+
"Hover + Delete → remove nearest seed\n"
119+
"+ / - → increase / decrease tolerance\n"
120+
"c → clear all seeds")
121+
122+
plot = ax.imshow(image)
123+
plot.set_colormap("gray")
124+
125+
# ── Persistent marker groups ──────────────────────────────────────────────────
126+
# Create named groups once so _refresh() can update them with .set() instead of
127+
# clear_markers() + add_circles(). Placing the placeholder far off-screen means
128+
# empty groups render nothing without needing a special empty-list code path.
129+
_HIDDEN = [[-9999.0, -9999.0]] # off-screen placeholder for an empty group
130+
131+
plot.add_circles(_HIDDEN, name="pos",
132+
facecolors="#00c853", edgecolors="#ffffff",
133+
radius=SEED_RADIUS_PIXELS)
134+
plot.add_circles(_HIDDEN, name="neg",
135+
facecolors="#b71c1c", edgecolors="#ffffff",
136+
radius=SEED_RADIUS_PIXELS)
137+
138+
# ── Helpers: marker refresh and mask push ────────────────────────────────────
139+
140+
def _refresh():
141+
"""Recompute mask and push updated markers + overlay in one go.
142+
143+
Updates the two persistent marker groups in-place (no clear → blank → add
144+
cycle) so there is no visible flicker when a seed is removed.
145+
Each group has its own fixed colour string so the JS fill_color field
146+
always receives a valid CSS colour (not a mixed list).
147+
"""
148+
global mask
149+
mask = _compute_mask(image, pos_seeds, neg_seeds, tolerance)
150+
151+
# Update offsets for each group; fall back to off-screen placeholder when empty.
152+
pos_offsets = [(c, r) for r, c in pos_seeds] or _HIDDEN
153+
neg_offsets = [(c, r) for r, c in neg_seeds] or _HIDDEN
154+
plot.markers["circles"]["pos"].set(offsets=pos_offsets)
155+
plot.markers["circles"]["neg"].set(offsets=neg_offsets)
156+
157+
# Transparent overlay — teal for positive mask regions.
158+
plot.set_overlay_mask(mask, color="#00e5ff", alpha=0.38)
159+
160+
161+
# ── Click handler ─────────────────────────────────────────────────────────────
162+
163+
@plot.on_click
164+
def _on_click(event):
165+
"""Left-click → positive seed; Shift+Left-click → negative seed."""
166+
# img_x = column, img_y = row (image-pixel coordinates)
167+
col = int(round(float(event.img_x)))
168+
row = int(round(float(event.img_y)))
169+
# Clamp to image bounds
170+
col = max(0, min(N - 1, col))
171+
row = max(0, min(N - 1, row))
172+
173+
if getattr(event, "shift_key", False):
174+
neg_seeds.append((row, col))
175+
else:
176+
pos_seeds.append((row, col))
177+
178+
_refresh()
179+
180+
181+
# ── Key bindings ──────────────────────────────────────────────────────────────
182+
183+
@plot.on_key('+')
184+
@plot.on_key('=') # '+' on most keyboards requires Shift; '=' is the unshifted key
185+
def _tol_up(event):
186+
"""Increase tolerance → flood-fill grows to wider intensity range."""
187+
global tolerance
188+
tolerance = min(TOL_MAX, round(tolerance + TOL_STEP, 4))
189+
_refresh()
190+
print(f" tolerance = {tolerance:.3f}", end="\r")
191+
192+
193+
@plot.on_key('-')
194+
def _tol_down(event):
195+
"""Decrease tolerance → flood-fill shrinks to narrower range."""
196+
global tolerance
197+
tolerance = max(TOL_MIN, round(tolerance - TOL_STEP, 4))
198+
_refresh()
199+
print(f" tolerance = {tolerance:.3f}", end="\r")
200+
201+
202+
@plot.on_key('c')
203+
def _clear(event):
204+
"""Clear all seeds and reset the mask."""
205+
pos_seeds.clear()
206+
neg_seeds.clear()
207+
_refresh()
208+
print(" seeds cleared", end="\r")
209+
210+
211+
@plot.on_key('Delete')
212+
@plot.on_key('Backspace')
213+
def _delete_nearest(event):
214+
"""Remove the seed (positive or negative) nearest to the cursor."""
215+
cx = float(event.img_x)
216+
cy = float(event.img_y) # img_y = row
217+
218+
best_dist = float("inf")
219+
best_list = None
220+
best_idx = -1
221+
222+
for lst in (pos_seeds, neg_seeds):
223+
for i, (r, c) in enumerate(lst):
224+
d = (c - cx) ** 2 + (r - cy) ** 2
225+
if d < best_dist:
226+
best_dist = d
227+
best_list = lst
228+
best_idx = i
229+
230+
if best_list is not None and best_dist <= (12 ** 2):
231+
best_list.pop(best_idx)
232+
_refresh()
233+
234+
235+
fig
236+
237+

0 commit comments

Comments
 (0)