Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ suite. What it does today:
Results become reusable analysis objects: an overlay, a new image, an **active
mask**, or ROI(s). The active-mask layer (Masks tab) supports morphological
cleanup (remove small objects, fill holes, dilate/erode/open/close,
skeletonize), restricts statistics, excludes regions from a plane fit (via
mask→ROI), and is saved to a `<scan>.masks.json` sidecar.
skeletonize) and restricts statistics directly; convert it to ROI(s) to
exclude regions from a plane fit. Masks are saved to a `<scan>.masks.json`
sidecar.
- **FFT tools** (the FFT viewer) — inspect the magnitude and radial profile with
q in nm⁻¹; overlay a draggable reciprocal-lattice grid and apply an affine
lattice correction; show Bragg-shell rings for a known structure; predict and
Expand Down
13 changes: 10 additions & 3 deletions probeflow/gui/dialogs/edge_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,14 @@ def _on_mode_changed(self) -> None:
self._schedule()

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

Expand Down Expand Up @@ -347,6 +352,8 @@ def _compute(self) -> EdgeDetectionResult:
high=float(self._canny_high.value()),
roi_mask=roi,
pixel_size_nm=self._pixel_size_nm,
pixel_size_x_nm=self._pixel_size_x_nm,
pixel_size_y_nm=self._pixel_size_y_nm,
source_channel=self._source_channel,
)
roi = self._active_roi_mask if self._grad_roi.isChecked() else None
Expand Down
20 changes: 20 additions & 0 deletions probeflow/gui/viewer/image_viewer_mask_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ def _refresh_mask_overlay(self) -> None:
self._zoom_lbl.clear_mask_overlay()
else:
self._zoom_lbl.set_mask_overlay(data)
self._warn_if_mask_channel_mismatch()

def _warn_if_mask_channel_mismatch(self) -> None:
"""Non-blocking warning when the active mask was made on another channel.

The mask still applies (shape is the only hard requirement), but a
same-shape mask from a different channel/processing state is
semantically stale — surface that rather than applying it silently.
"""
ms = getattr(self, "_image_mask_set", None)
mask = ms.active() if ms is not None else None
if mask is None or not hasattr(self, "_status_lbl"):
return
recorded = mask.parameters.get("source_channel")
current = self._edge_source_channel()
if recorded and current and recorded != current:
self._status_lbl.setText(
f"Note: active mask “{mask.name}” was made on channel "
f"“{recorded}”, now viewing “{current}”."
)

# ── Advanced Edge Detection dialog ─────────────────────────────────────────

Expand Down
21 changes: 20 additions & 1 deletion probeflow/processing/edge_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,20 @@ def canny_edges(
roi_mask: np.ndarray | None = None,
preset: str | None = None,
pixel_size_nm: float | None = None,
pixel_size_x_nm: float | None = None,
pixel_size_y_nm: float | None = None,
source_channel: str | None = None,
) -> EdgeDetectionResult:
"""Detect edges with the Canny algorithm (``skimage.feature.canny``).

Parameters
----------
sigma:
Gaussian smoothing width in pixels.
Gaussian smoothing width **in pixels**. ``skimage.feature.canny`` only
accepts a scalar sigma, so the smoothing is isotropic *in pixel space*.
On anisotropic scans (``pixel_size_x_nm != pixel_size_y_nm``) it is
therefore not isotropic in physical space; the recorded ``sigma_x_nm`` /
``sigma_y_nm`` express the per-axis physical extent for provenance.
threshold_mode:
``"percentile"`` (default) interprets *low*/*high* as percentiles
(0–100) of the gradient magnitude — robust across STM channels whose
Expand All @@ -120,6 +126,9 @@ def canny_edges(
preset:
Name in :data:`CANNY_PRESETS`; when given it overrides *sigma*/*low*/
*high*.
pixel_size_x_nm, pixel_size_y_nm:
Optional physical pixel spacings, recorded for provenance only (the
smoothing remains pixel-space). Fall back to *pixel_size_nm*.
"""
from skimage.feature import canny as _canny

Expand Down Expand Up @@ -174,6 +183,13 @@ def canny_edges(
"preset": preset,
"source_channel": source_channel,
}
# Per-axis physical extent of the (pixel-space) Gaussian, for provenance.
dx = pixel_size_x_nm or pixel_size_nm
dy = pixel_size_y_nm or pixel_size_nm
if dx:
params["sigma_x_nm"] = float(sigma) * float(dx)
if dy:
params["sigma_y_nm"] = float(sigma) * float(dy)
if pixel_size_nm is not None:
params["sigma_nm"] = float(sigma) * float(pixel_size_nm)

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

if normalize and output != "orientation":
peak = float(np.nanmax(np.abs(chosen))) if chosen.size else 0.0
Expand Down
18 changes: 18 additions & 0 deletions tests/test_edge_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ def test_pixel_size_records_sigma_nm(self):
res = canny_edges(_step_image(), sigma=2.0, pixel_size_nm=0.05)
assert res.parameters["sigma_nm"] == pytest.approx(0.1)

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


# ── Sobel / Scharr ─────────────────────────────────────────────────────────────

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

def test_roi_bounds_orientation_field(self):
# ROI restriction must also bound the returned orientation field, not
# just display/magnitude/edge_mask.
img = _step_image(edge_col=32)
roi = np.zeros_like(img, dtype=bool)
roi[:, :16] = True
res = gradient_filter(img, output="magnitude", roi_mask=roi)
assert res.gradient_orientation is not None
assert not np.any(res.gradient_orientation[:, 16:])

def test_invalid_operator_and_output_raise(self):
with pytest.raises(ValueError):
gradient_filter(_step_image(), operator="prewitt")
Expand Down
8 changes: 5 additions & 3 deletions tests/test_mask_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ def test_active_mask_restricts_statistics():
assert result.values["n_finite_pixels"] == 16 * 32


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