Skip to content

Commit e140c74

Browse files
Merge pull request #14 from SPMQT-Lab/edge-detection-followups
Edge-detection follow-ups: Canny anisotropy honesty, ROI/orientation, docs
2 parents 9ec7352 + 88a0c7a commit e140c74

6 files changed

Lines changed: 76 additions & 9 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,9 @@ suite. What it does today:
6262
Results become reusable analysis objects: an overlay, a new image, an **active
6363
mask**, or ROI(s). The active-mask layer (Masks tab) supports morphological
6464
cleanup (remove small objects, fill holes, dilate/erode/open/close,
65-
skeletonize), restricts statistics, excludes regions from a plane fit (via
66-
mask→ROI), and is saved to a `<scan>.masks.json` sidecar.
65+
skeletonize) and restricts statistics directly; convert it to ROI(s) to
66+
exclude regions from a plane fit. Masks are saved to a `<scan>.masks.json`
67+
sidecar.
6768
- **FFT tools** (the FFT viewer) — inspect the magnitude and radial profile with
6869
q in nm⁻¹; overlay a draggable reciprocal-lattice grid and apply an affine
6970
lattice correction; show Bragg-shell rings for a known structure; predict and

probeflow/gui/dialogs/edge_detection.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,9 +314,14 @@ def _on_mode_changed(self) -> None:
314314
self._schedule()
315315

316316
def _update_sigma_label(self) -> None:
317-
if self._pixel_size_nm:
318-
nm = self._canny_sigma.value() * self._pixel_size_nm
319-
self._canny_sigma_lbl.setText(f"≈ {nm:.3g} nm")
317+
dx, dy = self._pixel_size_x_nm, self._pixel_size_y_nm
318+
sigma = self._canny_sigma.value()
319+
if dx and dy and abs(dx - dy) > 1e-9:
320+
# Anisotropic pixels: Canny smooths isotropically in *pixels*, so
321+
# the physical extent differs per axis — show both, not one value.
322+
self._canny_sigma_lbl.setText(f"≈ {sigma * dx:.3g}×{sigma * dy:.3g} nm")
323+
elif dx:
324+
self._canny_sigma_lbl.setText(f"≈ {sigma * dx:.3g} nm")
320325
else:
321326
self._canny_sigma_lbl.setText("")
322327

@@ -347,6 +352,8 @@ def _compute(self) -> EdgeDetectionResult:
347352
high=float(self._canny_high.value()),
348353
roi_mask=roi,
349354
pixel_size_nm=self._pixel_size_nm,
355+
pixel_size_x_nm=self._pixel_size_x_nm,
356+
pixel_size_y_nm=self._pixel_size_y_nm,
350357
source_channel=self._source_channel,
351358
)
352359
roi = self._active_roi_mask if self._grad_roi.isChecked() else None

probeflow/gui/viewer/image_viewer_mask_mixin.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ def _refresh_mask_overlay(self) -> None:
7474
self._zoom_lbl.clear_mask_overlay()
7575
else:
7676
self._zoom_lbl.set_mask_overlay(data)
77+
self._warn_if_mask_channel_mismatch()
78+
79+
def _warn_if_mask_channel_mismatch(self) -> None:
80+
"""Non-blocking warning when the active mask was made on another channel.
81+
82+
The mask still applies (shape is the only hard requirement), but a
83+
same-shape mask from a different channel/processing state is
84+
semantically stale — surface that rather than applying it silently.
85+
"""
86+
ms = getattr(self, "_image_mask_set", None)
87+
mask = ms.active() if ms is not None else None
88+
if mask is None or not hasattr(self, "_status_lbl"):
89+
return
90+
recorded = mask.parameters.get("source_channel")
91+
current = self._edge_source_channel()
92+
if recorded and current and recorded != current:
93+
self._status_lbl.setText(
94+
f"Note: active mask “{mask.name}” was made on channel "
95+
f"“{recorded}”, now viewing “{current}”."
96+
)
7797

7898
# ── Advanced Edge Detection dialog ─────────────────────────────────────────
7999

probeflow/processing/edge_detection.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,20 @@ def canny_edges(
100100
roi_mask: np.ndarray | None = None,
101101
preset: str | None = None,
102102
pixel_size_nm: float | None = None,
103+
pixel_size_x_nm: float | None = None,
104+
pixel_size_y_nm: float | None = None,
103105
source_channel: str | None = None,
104106
) -> EdgeDetectionResult:
105107
"""Detect edges with the Canny algorithm (``skimage.feature.canny``).
106108
107109
Parameters
108110
----------
109111
sigma:
110-
Gaussian smoothing width in pixels.
112+
Gaussian smoothing width **in pixels**. ``skimage.feature.canny`` only
113+
accepts a scalar sigma, so the smoothing is isotropic *in pixel space*.
114+
On anisotropic scans (``pixel_size_x_nm != pixel_size_y_nm``) it is
115+
therefore not isotropic in physical space; the recorded ``sigma_x_nm`` /
116+
``sigma_y_nm`` express the per-axis physical extent for provenance.
111117
threshold_mode:
112118
``"percentile"`` (default) interprets *low*/*high* as percentiles
113119
(0–100) of the gradient magnitude — robust across STM channels whose
@@ -120,6 +126,9 @@ def canny_edges(
120126
preset:
121127
Name in :data:`CANNY_PRESETS`; when given it overrides *sigma*/*low*/
122128
*high*.
129+
pixel_size_x_nm, pixel_size_y_nm:
130+
Optional physical pixel spacings, recorded for provenance only (the
131+
smoothing remains pixel-space). Fall back to *pixel_size_nm*.
123132
"""
124133
from skimage.feature import canny as _canny
125134

@@ -174,6 +183,13 @@ def canny_edges(
174183
"preset": preset,
175184
"source_channel": source_channel,
176185
}
186+
# Per-axis physical extent of the (pixel-space) Gaussian, for provenance.
187+
dx = pixel_size_x_nm or pixel_size_nm
188+
dy = pixel_size_y_nm or pixel_size_nm
189+
if dx:
190+
params["sigma_x_nm"] = float(sigma) * float(dx)
191+
if dy:
192+
params["sigma_y_nm"] = float(sigma) * float(dy)
177193
if pixel_size_nm is not None:
178194
params["sigma_nm"] = float(sigma) * float(pixel_size_nm)
179195

@@ -270,6 +286,9 @@ def gradient_filter(
270286
if roi is not None:
271287
chosen = np.where(roi, chosen, 0.0)
272288
magnitude = np.where(roi, magnitude, 0.0)
289+
# Keep the returned orientation field ROI-bounded too, so downstream
290+
# consumers never see out-of-ROI gradient directions.
291+
orientation = np.where(roi, orientation, 0.0)
273292

274293
if normalize and output != "orientation":
275294
peak = float(np.nanmax(np.abs(chosen))) if chosen.size else 0.0

tests/test_edge_detection.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ def test_pixel_size_records_sigma_nm(self):
8686
res = canny_edges(_step_image(), sigma=2.0, pixel_size_nm=0.05)
8787
assert res.parameters["sigma_nm"] == pytest.approx(0.1)
8888

89+
def test_anisotropic_records_per_axis_sigma_nm(self):
90+
# Canny smooths in pixels (scalar sigma); the recorded physical extent
91+
# is per-axis on anisotropic scans, and is not described as isotropic.
92+
res = canny_edges(_step_image(), sigma=2.0,
93+
pixel_size_x_nm=0.05, pixel_size_y_nm=0.20)
94+
assert res.parameters["sigma_x_nm"] == pytest.approx(0.1)
95+
assert res.parameters["sigma_y_nm"] == pytest.approx(0.4)
96+
8997

9098
# ── Sobel / Scharr ─────────────────────────────────────────────────────────────
9199

@@ -171,6 +179,16 @@ def test_x_and_y_outputs_differ_for_diagonal(self):
171179
# y-slope is twice the x-slope, so |gy| median should exceed |gx|.
172180
assert np.median(np.abs(gy)) > np.median(np.abs(gx))
173181

182+
def test_roi_bounds_orientation_field(self):
183+
# ROI restriction must also bound the returned orientation field, not
184+
# just display/magnitude/edge_mask.
185+
img = _step_image(edge_col=32)
186+
roi = np.zeros_like(img, dtype=bool)
187+
roi[:, :16] = True
188+
res = gradient_filter(img, output="magnitude", roi_mask=roi)
189+
assert res.gradient_orientation is not None
190+
assert not np.any(res.gradient_orientation[:, 16:])
191+
174192
def test_invalid_operator_and_output_raise(self):
175193
with pytest.raises(ValueError):
176194
gradient_filter(_step_image(), operator="prewitt")

tests/test_mask_integration.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ def test_active_mask_restricts_statistics():
3333
assert result.values["n_finite_pixels"] == 16 * 32
3434

3535

36-
def test_active_mask_excludes_region_from_plane_fit():
37-
# A tilted plane plus a bright contaminated blob. Fitting with the blob
38-
# excluded recovers a near-flat residual; including it skews the fit.
36+
def test_mask_to_roi_excludes_region_from_plane_fit():
37+
# The active mask excludes regions from a plane fit *via the mask→ROI
38+
# bridge* (subtract_background takes an ROI, not a mask). A tilted plane
39+
# plus a bright contaminated blob: excluding the blob recovers a near-flat
40+
# residual; including it skews the fit.
3941
yy, xx = np.mgrid[0:64, 0:64]
4042
plane = 0.1 * xx + 0.05 * yy
4143
img = plane.astype(np.float64).copy()

0 commit comments

Comments
 (0)