Skip to content

Commit b73046c

Browse files
committed
Add overlay mask functionality for interactive image segmentation
1 parent 90daec6 commit b73046c

5 files changed

Lines changed: 361 additions & 157 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="#69f0ae", edgecolors="#ffffff",
133+
radius=SEED_RADIUS_PIXELS)
134+
plot.add_circles(_HIDDEN, name="neg",
135+
facecolors="#ff5252", 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+

‎anyplotlib/figure_esm.js‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,56 @@ function render({ model, el }) {
995995
blitCache.w=iw; blitCache.h=ih;
996996
_blit2d(oc, st, imgW, imgH, ctx);
997997
}
998+
999+
// ── Overlay mask compositing ─────────────────────────────────────────────
1000+
// overlay_mask_b64: base64 uint8 bytes (0|255), same iw×ih as image.
1001+
// Rendered at overlay_mask_alpha on top of the base image without clearing.
1002+
const mob64=st.overlay_mask_b64||'';
1003+
if(mob64){
1004+
const mColor=st.overlay_mask_color||'#ff4444';
1005+
const mAlpha=st.overlay_mask_alpha!=null?st.overlay_mask_alpha:0.4;
1006+
const mKey=mob64+'|'+mColor+'|'+mAlpha;
1007+
if(!p.maskCache||p.maskCache.key!==mKey){
1008+
// Parse hex colour → r,g,b
1009+
let mr=255,mg=68,mb=68;
1010+
if(mColor.startsWith('#')&&mColor.length===7){
1011+
mr=parseInt(mColor.slice(1,3),16);
1012+
mg=parseInt(mColor.slice(3,5),16);
1013+
mb=parseInt(mColor.slice(5,7),16);
1014+
}
1015+
let mBytes;
1016+
try{const bin=atob(mob64);mBytes=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)mBytes[i]=bin.charCodeAt(i);}catch(_){mBytes=null;}
1017+
if(mBytes&&mBytes.length===iw*ih){
1018+
const mImg=new ImageData(iw,ih);
1019+
// Write colour where mask=255; transparent where mask=0.
1020+
// Store full-alpha pixel; globalAlpha controls final transparency.
1021+
const buf4=new ArrayBuffer(4);const dv4=new DataView(buf4);const u32m=new Uint32Array(buf4);
1022+
dv4.setUint8(0,mr);dv4.setUint8(1,mg);dv4.setUint8(2,mb);dv4.setUint8(3,255);
1023+
const opaque=u32m[0];
1024+
const out32m=new Uint32Array(mImg.data.buffer);
1025+
for(let i=0;i<mBytes.length;i++)out32m[i]=mBytes[i]?opaque:0;
1026+
const moc=new OffscreenCanvas(iw,ih);
1027+
moc.getContext('2d').putImageData(mImg,0,0);
1028+
p.maskCache={key:mKey,bitmap:moc};
1029+
}else{p.maskCache=null;}
1030+
}
1031+
if(p.maskCache&&p.maskCache.bitmap){
1032+
// Blit at the same zoom/pan as the base image (inline, no clearRect).
1033+
const {x:_mx,y:_my,w:_mw,h:_mh}=_imgFitRect(iw,ih,imgW,imgH);
1034+
const _mz=st.zoom,_mcx=st.center_x,_mcy=st.center_y;
1035+
ctx.save();ctx.globalAlpha=mAlpha;ctx.imageSmoothingEnabled=false;
1036+
if(_mz>=1.0){
1037+
const _vw=iw/_mz,_vh=ih/_mz;
1038+
const _sx=Math.max(0,Math.min(iw-_vw,_mcx*iw-_vw/2));
1039+
const _sy=Math.max(0,Math.min(ih-_vh,_mcy*ih-_vh/2));
1040+
ctx.drawImage(p.maskCache.bitmap,_sx,_sy,_vw,_vh,_mx,_my,_mw,_mh);
1041+
}else{
1042+
const _dw=_mw*_mz,_dh=_mh*_mz;
1043+
ctx.drawImage(p.maskCache.bitmap,0,0,iw,ih,_mx+(_mw-_dw)/2,_my+(_mh-_dh)/2,_dw,_dh);
1044+
}
1045+
ctx.restore();
1046+
}
1047+
}
9981048
// Axes / scalebar / colorbar
9991049
_drawAxes2d(p);
10001050
drawScaleBar2d(p);
@@ -2389,6 +2439,8 @@ function render({ model, el }) {
23892439
// Store pan start in canvas-pixel coords so the drag delta is also
23902440
// in canvas-pixel space and matches fr.w/fr.h (both canvas-pixel).
23912441
panStart={mx,my,cx:st.center_x,cy:st.center_y};
2442+
// Track potential click: distance + time guards distinguish click from pan.
2443+
p.clickCandidate={mx,my,t:Date.now(),shiftKey:e.shiftKey};
23922444
p.isPanning=true; overlayCanvas.style.cursor='grabbing'; e.preventDefault();
23932445
});
23942446
document.addEventListener('mousemove',(e)=>{
@@ -2404,6 +2456,8 @@ function render({ model, el }) {
24042456
const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH);
24052457
const z=st.zoom;
24062458
const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH);
2459+
// Invalidate click candidate once the cursor has clearly moved (>4 px).
2460+
if(p.clickCandidate){const _dx=cmx-p.clickCandidate.mx,_dy=cmy-p.clickCandidate.my;if(_dx*_dx+_dy*_dy>16)p.clickCandidate=null;}
24072461
localOnly=true;
24082462
st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/z));
24092463
st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/z));
@@ -2428,6 +2482,33 @@ function render({ model, el }) {
24282482
const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B);
24292483
const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH);
24302484
const {mx:cmx,my:cmy}=_clientPos(e,overlayCanvas,imgW,imgH);
2485+
// ── Click detection: short-duration + small-movement mousedown/up ────────
2486+
// Criteria: candidate still alive (not cleared by mousemove) AND ≤300 ms.
2487+
// We also re-check final distance as a safety net for document-level moves
2488+
// that didn't fire our mousemove guard (e.g. rapid trackpad flicks).
2489+
if(p.clickCandidate){
2490+
const _cc=p.clickCandidate; p.clickCandidate=null;
2491+
const _dx=cmx-_cc.mx, _dy=cmy-_cc.my;
2492+
const _dist2=_dx*_dx+_dy*_dy;
2493+
const _dt=Date.now()-_cc.t;
2494+
if(_dist2<=25&&_dt<=350){
2495+
// Genuine click — skip pan-settle, emit on_click with image coords.
2496+
const [imgX,imgY]=_canvasToImg2d(_cc.mx,_cc.my,st,imgW,imgH);
2497+
const xArr=st.x_axis||[], yArr=st.y_axis||[];
2498+
const _iw=st.image_width||1, _ih=st.image_height||1;
2499+
const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX;
2500+
const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY;
2501+
_emitEvent(p.id,'on_click',null,{
2502+
img_x:imgX, img_y:imgY,
2503+
phys_x:physX, phys_y:physY,
2504+
shift_key:_cc.shiftKey,
2505+
mouse_x:_cc.mx, mouse_y:_cc.my,
2506+
});
2507+
model.save_changes();
2508+
return;
2509+
}
2510+
}
2511+
// ── Normal pan settle ───────────────────────────────────────────────────
24312512
st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom));
24322513
st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom));
24332514
model.set(`panel_${p.id}_json`, JSON.stringify(p.state));

0 commit comments

Comments
 (0)