|
| 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 | + |
0 commit comments