From 5111cf2786ff65a0c058c5ccace4b86252c2d862 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 15 Jun 2026 21:27:56 +1000 Subject: [PATCH 1/3] Expose 'Linear' row alignment in the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend align_rows supports median, mean, and linear (linear also removes a straight slope within each row), and the Definitions help documents all three — but the GUI's "Align rows" dropdown only offered None/Median/Mean, so the documented 'linear' option was unreachable. Add "Linear" to the Align rows control (probeflow/gui/processing.py: combo items, state align_map, set_state reverse map, tooltip) and to the menu-bar mirror (probeflow/gui/dialogs/image_viewer_chrome_mixin.py). The state→op pipeline already passes the method through generically, so linear works end to end. Test: test_align_rows_exposes_linear_option. Co-Authored-By: Claude Opus 4.8 --- probeflow/gui/dialogs/image_viewer_chrome_mixin.py | 2 +- probeflow/gui/processing.py | 9 +++++---- tests/test_gui_processing_panel.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py index e0398d1..e3df784 100644 --- a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py @@ -241,7 +241,7 @@ def _build_viewer_menu_bar(self) -> None: processing_menu.addSeparator() self._add_combo_menu( processing_menu, "Align rows", self._processing_panel._align_combo, - ["None", "Median", "Mean"], + ["None", "Median", "Mean", "Linear"], ) self._add_combo_menu( processing_menu, "Bad line correction", self._processing_panel._bad_lines_combo, diff --git a/probeflow/gui/processing.py b/probeflow/gui/processing.py index b0f2d9a..9dbd438 100644 --- a/probeflow/gui/processing.py +++ b/probeflow/gui/processing.py @@ -129,10 +129,11 @@ def _col_lbl(text: str, target): line_lbl.setAlignment(Qt.AlignCenter) lay.addWidget(line_lbl) - self._align_combo = _combo_row("Align rows:", ["None", "Median", "Mean"]) + self._align_combo = _combo_row("Align rows:", ["None", "Median", "Mean", "Linear"]) self._align_combo.setToolTip( "Level each scan line by subtracting its median or mean, removing " - "row-to-row offsets and slow tilt along the slow-scan direction." + "row-to-row offsets and slow tilt along the slow-scan direction. " + "'Linear' also fits and removes a straight slope within each row." ) self._bad_lines_combo = _combo_row( @@ -340,7 +341,7 @@ def _col_lbl(text: str, target): lay.addWidget(self._filter_section) def state(self) -> dict: - align_map = {0: None, 1: "median", 2: "mean"} + align_map = {0: None, 1: "median", 2: "mean", 3: "linear"} bad_map = {0: None, 1: "step", 2: "mad"} cfg = { "align_rows": align_map[self._align_combo.currentIndex()], @@ -382,7 +383,7 @@ def set_state(self, state: dict | None) -> None: state = state or {} old_block = self._align_combo.blockSignals(True) self._align_combo.setCurrentIndex( - {None: 0, "median": 1, "mean": 2}.get(state.get("align_rows"), 0)) + {None: 0, "median": 1, "mean": 2, "linear": 3}.get(state.get("align_rows"), 0)) self._align_combo.blockSignals(old_block) self._bad_lines_combo.setCurrentIndex( {None: 0, "step": 1, "step_segments": 1, diff --git a/tests/test_gui_processing_panel.py b/tests/test_gui_processing_panel.py index 8f446ae..91a92dd 100644 --- a/tests/test_gui_processing_panel.py +++ b/tests/test_gui_processing_panel.py @@ -145,6 +145,20 @@ def test_browse_quick_panel_emits_only_thumbnail_corrections(qapp): assert panel.state() == {"align_rows": "median", "remove_bad_lines": None} +def test_align_rows_exposes_linear_option(qapp): + # The backend align_rows supports median/mean/linear; the GUI must offer all + # three so the documented 'linear' option is actually reachable. + from probeflow.gui import ProcessingControlPanel + + panel = ProcessingControlPanel("viewer_full") + items = [panel._align_combo.itemText(i) for i in range(panel._align_combo.count())] + assert items == ["None", "Median", "Mean", "Linear"] + + panel.set_state({"align_rows": "linear"}) + assert panel._align_combo.currentIndex() == 3 + assert panel.state()["align_rows"] == "linear" + + def test_viewer_full_panel_round_trips_standard_processing_state(qapp): from PySide6.QtWidgets import QCheckBox, QLabel, QPushButton from probeflow.gui import ProcessingControlPanel From 59af6967845443a0052186c292d5baf4eb4a79d2 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 15 Jun 2026 21:58:38 +1000 Subject: [PATCH 2/3] Add a Measurements reference tab to the in-app help MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Definitions dialog explained processing, ROIs, and how-tos but said nothing about the measurements a user can take. Add a student-friendly Measurements reference covering the image measurements, in the same format as the rest (plain-language summary, an "In practice" lead, the exact formula, cautions). - New _MEASUREMENT_ENTRIES + render_measurements_html + _MeasurementsPanel in probeflow/gui/dialogs/definitions.py: ten entries — Distance, Angle, Line profile (and Δ), Line periodicity, ROI statistics, Step height, Feature maxima, Point mask / FFT, Pair correlation, Feature → lattice. Each formula was read from its source module so the maths matches the code (e.g. step height = mean_b − mean_a; RMS roughness = sqrt(mean((z−mean)²)); g(r) with edge correction; lattice RMS displacement). - New "Measurements" tab between Processing and ROI Actions; _TAB_INDEX and set_reference_tab updated. - Reachable from Help menu + command finder: new help.measurements ViewerCommand (shortcuts.py), Help-menu action (image_viewer_chrome_mixin.py), and _show_viewer_measurements opening the tab (image_viewer_toolbar_mixin.py). Scope: image measurements only (excludes spectroscopy Spectrum Δ). Tests: test_measurements_reference_has_entries_and_formulas; tab-focus and command-finder tests extended for measurements. Co-Authored-By: Claude Opus 4.8 --- probeflow/gui/dialogs/definitions.py | 387 +++++++++++++++++- .../gui/dialogs/image_viewer_chrome_mixin.py | 5 + .../gui/viewer/image_viewer_toolbar_mixin.py | 11 + probeflow/gui/viewer/shortcuts.py | 5 + tests/test_definitions_dialog.py | 37 +- tests/test_viewer_shortcuts.py | 1 + 6 files changed, 444 insertions(+), 2 deletions(-) diff --git a/probeflow/gui/dialogs/definitions.py b/probeflow/gui/dialogs/definitions.py index f8d77c5..560b04d 100644 --- a/probeflow/gui/dialogs/definitions.py +++ b/probeflow/gui/dialogs/definitions.py @@ -1134,6 +1134,358 @@ class _HowToEntry: ) +_MEASUREMENT_ENTRIES: tuple[_DefinitionEntry, ...] = ( + _DefinitionEntry( + title="Distance", + params=("line ROI", "length_m", "dx_m", "dy_m", "angle_deg"), + summary=( + "Measures the straight-line distance between two points using a line " + "ROI, in real units from the scan calibration. It also reports the " + "horizontal and vertical components (Δx, Δy) and the line's angle from " + "horizontal. Use it for feature sizes, spacings, and how far apart two " + "things are." + ), + in_practice=( + "Draw a line ROI across the gap you want, then take the Distance " + "measurement. The length is calibrated, so it is a real nanometre " + "distance, not pixels." + ), + equations=( + "from line endpoints (x1, y1) -> (x2, y2) in pixels:\n" + " dx_m = (x2 - x1) * pixel_size_x_m\n" + " dy_m = (y2 - y1) * pixel_size_y_m\n" + " length_m = sqrt(dx_m^2 + dy_m^2)\n" + " angle_deg = atan2(|dy_m|, |dx_m|) (from the horizontal)", + ), + details=( + "The two axes are scaled by their own pixel sizes before the length is " + "computed, so distances are correct even when the scan has " + "non-square pixels.", + ), + cautions=( + "The number is only as good as where you place the endpoints. Zoom in " + "and snap them to the real feature edges; a line drawn a few pixels off " + "changes the reading.", + ), + ), + _DefinitionEntry( + title="Angle", + params=("two line ROIs", "angle_deg in [0, 90]"), + summary=( + "Measures the angle between two directions — for example two step " + "edges, or two lattice rows — by drawing two line ROIs. The result is " + "the acute angle between them, always reported between 0 and 90 " + "degrees." + ), + in_practice=( + "Draw two line ROIs along the directions you care about, select both, " + "and take the Angle measurement. Direction is what matters, not which " + "way you drew each line." + ), + equations=( + "line vectors a and b in physical units (scaled by pixel size):\n" + " cos(theta) = (a . b) / (|a| * |b|)\n" + " angle_deg = acos(clamp(cos(theta), -1, 1))\n" + " if angle_deg > 90: angle_deg = 180 - angle_deg", + ), + details=( + "Because only the directions matter, the angle is folded into 0–90°: " + "drawing a line the other way round gives the same answer.", + ), + cautions=( + "Very short lines make the direction uncertain — a one-pixel wobble at " + "the ends swings the angle. Draw each line as long as the feature " + "allows.", + ), + ), + _DefinitionEntry( + title="Line profile (and Δ)", + params=("line ROI", "width", "length", "delta_y", "delta_x"), + summary=( + "Reads out the surface height along a line as a graph — the " + "cross-section you use to measure step heights, feature widths, and " + "spacings. Drop two markers on the graph to read the height difference " + "(Δy) and horizontal separation (Δx) between them." + ), + in_practice=( + "Draw a line across a step or feature; the profile updates live in the " + "panel below. Increase the line width to average out noise, and use the " + "two markers to read a step height (Δy) directly." + ), + equations=( + "sample height along the line:\n" + " s runs from 0 to the physical length of the line\n" + " z(s) = image sampled along the line (bilinear)\n" + " width > 1 px: average finite pixels in a perpendicular swath\n\n" + "two-marker delta:\n" + " delta_x = |s_2 - s_1| (physical distance along the line)\n" + " delta_y = z(s_2) - z(s_1) (height difference)", + ), + details=( + "A width greater than one pixel averages a strip perpendicular to the " + "line, which smooths a noisy profile while keeping the same length " + "axis. The length axis is calibrated, so spacings read directly in " + "nanometres.", + ), + cautions=( + "Averaging over a wide swath blurs sloped or curved features — keep the " + "width small when the step you are measuring is short or tilted.", + ), + ), + _DefinitionEntry( + title="Line periodicity", + params=( + "line profile", + "method = autocorrelation | peak_spacing | fft", + "period_m", + ), + summary=( + "Estimates the repeat spacing of a regular pattern sampled along a line " + "profile — for instance the period of an atomic row or a standing-wave " + "ripple. It reports one characteristic period (and how many repeats fit " + "along the line)." + ), + in_practice=( + "Draw a line along the periodic direction (several repeats long), then " + "estimate periodicity from the line-profile tools. A longer line gives " + "a more reliable period." + ), + equations=( + "detrend the profile z(s), then by method:\n" + " autocorrelation: C(lag) = sum_s z(s) z(s + lag);\n" + " period = first strong off-zero peak in C\n" + " peak_spacing: period = median spacing of detected profile peaks\n" + " fft: period = 1 / (dominant spatial frequency of z(s))\n\n" + "n_periods = line_length / period", + ), + details=( + "Autocorrelation (the default) is the most robust on noisy data: it " + "asks 'how far must I shift the profile for it to line up with itself " + "again?'. The FFT method is sharpest when the pattern is clean and " + "spans many repeats.", + ), + cautions=( + "A line that covers only one or two repeats cannot pin a period down. " + "Make sure the line spans several periods, and keep it parallel to the " + "pattern, not across it.", + ), + ), + _DefinitionEntry( + title="ROI statistics", + params=( + "area ROI", + "mean_height", + "median_height", + "std_height", + "rms_roughness", + "area", + ), + summary=( + "Summarises the heights inside an area ROI: the average and middle " + "height, the spread, the surface roughness, the min/max, and the " + "physical area. Use it to characterise a patch — how rough a terrace " + "is, how tall an island sits, how much area a phase covers." + ), + in_practice=( + "Draw an area ROI over the patch you care about and read its " + "statistics. Level the image first (row align / background) so heights " + "are measured against a flat reference." + ), + equations=( + "over finite pixels z inside the ROI mask:\n" + " mean_height = mean(z)\n" + " median_height = median(z)\n" + " std_height = std(z)\n" + " rms_roughness = sqrt(mean((z - mean(z))^2)) (Sq)\n" + " peak_to_peak = max(z) - min(z)\n" + " area = (number of selected pixels) * pixel_size_x_m * pixel_size_y_m", + ), + details=( + "'RMS roughness' (Sq) is the root-mean-square height deviation from the " + "mean — the standard single-number measure of how rough a surface is. " + "Non-finite (gap) pixels are ignored, and the area counts only the " + "selected finite pixels.", + ), + cautions=( + "Heights are relative to whatever reference the current processing " + "leaves in place. A residual tilt or background inflates roughness and " + "shifts the mean — level the surface before trusting these numbers.", + ), + ), + _DefinitionEntry( + title="Step height", + params=("two area ROIs", "height_difference"), + summary=( + "Measures the height difference between two flat regions — the classic " + "way to read a terrace or island step. Draw one ROI on the upper level " + "and one on the lower, and it reports the difference between their " + "average heights." + ), + in_practice=( + "Place two area ROIs on the flat areas either side of the step (not on " + "the step itself), select both, and take Step height. Averaging over a " + "patch beats reading two single pixels." + ), + equations=( + "over finite pixels in each ROI:\n" + " mean_a = mean(z in ROI A)\n" + " mean_b = mean(z in ROI B)\n" + " height_difference = mean_b - mean_a", + ), + details=( + "Using the mean over a whole region (rather than two clicked points) " + "averages away pixel noise, giving a much more stable step height. The " + "per-ROI medians and standard deviations are also recorded.", + ), + cautions=( + "Both regions must sit on genuinely flat terrace, not on the step face " + "or on adsorbates. A tilt across the image biases the difference — " + "level first, and keep the two ROIs close to the step.", + ), + ), + _DefinitionEntry( + title="Feature maxima", + params=( + "threshold_mode = above | below | between", + "threshold_low / high", + "min_distance_px", + ), + summary=( + "Automatically finds the bright peaks (or dark pits) in the image — the " + "positions of atoms, molecules, or islands — and drops a point at each " + "one. The detected points become a list you can count, measure, or feed " + "to the pair-correlation and lattice tools." + ), + in_practice=( + "Set the polarity (above for bright maxima, below for dark minima), a " + "height threshold, and a minimum spacing so each feature is counted " + "once. Preview, then convert the peaks to point ROIs." + ), + equations=( + "keep a pixel as a candidate when it passes the threshold:\n" + " above: z >= threshold_low\n" + " below: z <= threshold_high\n" + " between: threshold_low <= z <= threshold_high\n" + "local maxima are then thinned so no two are closer than\n" + " min_distance_px (one detection per feature)\n" + "n_points = number of detected features", + ), + details=( + "The minimum-distance rule stops a single broad feature from being " + "counted many times — only the strongest pixel within that radius " + "survives. 'below' mode detects pits/minima by the same logic with the " + "sign flipped.", + ), + cautions=( + "Too low a threshold or too small a spacing counts noise as features; " + "too high misses real ones. Tune against the preview, and remember the " + "threshold is in the image's height units, which shift if you reprocess.", + ), + ), + _DefinitionEntry( + title="Point mask / FFT", + params=("point set", "dominant_frequency"), + summary=( + "Takes a set of points (your point ROIs or detected feature maxima), " + "stamps them onto a blank image, and Fourier-transforms that — turning " + "an arrangement of points into its repeating spacings and directions. " + "Bright spots in the result reveal the dominant lattice spacing of the " + "points." + ), + in_practice=( + "Detect or place the points first, then run Point mask / FFT. Bright " + "off-centre spots mark the main repeat directions; their distance from " + "the centre gives the spacing (spacing = 1 / frequency)." + ), + equations=( + "M(x, y) = 1 at each point, 0 elsewhere\n" + "F(qx, qy) = |fftshift(fft2(M))|\n" + "qx, qy from fftfreq with the physical pixel size (cycles per length)\n" + "dominant spacing = 1 / |q| of the brightest off-centre peak", + ), + details=( + "Because it transforms only the point positions (not the height data), " + "it isolates how the features are arranged from how tall they are — a " + "clean way to see order in a scatter of detections.", + ), + cautions=( + "A handful of points gives a noisy, hard-to-read transform; it needs " + "many well-detected features to show clear spots. Stray or missed " + "detections smear the pattern.", + ), + ), + _DefinitionEntry( + title="Pair correlation", + params=("point set", "nn_median", "g(r)"), + summary=( + "Describes how a set of points is arranged relative to each other: it " + "measures every point's distance to its nearest neighbour and builds " + "the pair-correlation function g(r), which shows at what separations " + "points tend to sit. It is the standard way to quantify ordering, " + "spacing, and clustering." + ), + in_practice=( + "Detect features or place point ROIs, then run Pair correlation. The " + "nearest-neighbour median is a quick characteristic spacing; peaks in " + "g(r) mark preferred separations (an ordered lattice gives sharp " + "peaks)." + ), + equations=( + "pairwise distances d_ij = |r_i - r_j| (physical units)\n" + "nearest-neighbour: nn_i = min_{j != i} d_ij; report median(nn_i)\n\n" + "g(r): histogram all d_ij into radial bins, then normalise by the\n" + " number of pairs, the point density, and the bin's annulus area,\n" + " with an edge correction for pairs cut off by the ROI boundary", + ), + details=( + "g(r) is built so that a completely random arrangement averages to 1; " + "values above 1 mean points are more likely than random at that " + "separation (a preferred spacing), below 1 means less likely. The edge " + "correction stops the finite ROI from artificially suppressing long " + "distances.", + ), + cautions=( + "Reliable statistics need many points; a few detections give a noisy " + "g(r). Missed or spurious features distort the nearest-neighbour " + "distance, so check the detection first.", + ), + ), + _DefinitionEntry( + title="Feature → lattice", + params=("point set", "ideal lattice", "rms_displacement_m"), + summary=( + "Compares detected features against an ideal, perfectly regular lattice " + "and measures how far each one is displaced from where it 'should' be. " + "The single-number result — the RMS displacement — quantifies disorder, " + "strain, or distortion in an otherwise periodic arrangement." + ), + in_practice=( + "Detect the features, define or fit the ideal lattice, then run " + "Feature-to-lattice. A small RMS displacement means a well-ordered " + "lattice; a large one flags strain or disorder." + ), + equations=( + "match each feature to its nearest ideal lattice site (within a radius)\n" + "displacement d_k = |feature_k - matched_site_k|\n" + "rms_displacement = sqrt(mean(d_k^2)) over matched features\n" + "reported in pixels and in metres", + ), + details=( + "Only features that fall within the match radius of a lattice site are " + "counted, so a few stray detections do not dominate the result. The RMS " + "displacement is the root-mean-square of how far the real features sit " + "from the ideal grid.", + ), + cautions=( + "The answer depends on the ideal lattice you compare against — a wrong " + "lattice constant or orientation inflates the displacement. Make sure " + "the reference lattice matches the real one before reading disorder " + "from this number.", + ), + ), +) + + _HOWTO_ENTRIES: tuple[_HowToEntry, ...] = ( _HowToEntry( title="Open an image and flatten it", @@ -1690,6 +2042,27 @@ def render_roi_reference_html(theme: Mapping[str, object] | None = None) -> str: ) +def render_measurements_html(theme: Mapping[str, object] | None = None) -> str: + """Return theme-aware HTML for the image-measurements reference.""" + return _render_reference_html( + title="Measurements Reference", + intro=( + "These are the measurements you can take from the Measure tab. Every " + "one works on the real, calibrated data — physical heights and " + "distances in metres/nanometres and angles in degrees — not on the " + "colours on screen. Each result is added to the Measure-tab table with " + "its units, kept with the ROI it came from, and exported with the " + "image. Each entry below has a plain-language summary, when to reach " + "for it, the formula the program uses, and cautions. Level the image " + "first (row alignment / background) so heights are measured against a " + "flat reference." + ), + entries=_MEASUREMENT_ENTRIES, + theme=theme, + block_label="Computation", + ) + + def _render_howto_entry(entry: _HowToEntry) -> str: blocks = [f'
'] blocks.append(f"

{escape(entry.title)}

") @@ -1728,6 +2101,7 @@ def render_howto_html(theme: Mapping[str, object] | None = None) -> str: _DEFINITIONS_HTML = render_definitions_html() _ROI_REFERENCE_HTML = render_roi_reference_html() +_MEASUREMENTS_HTML = render_measurements_html() _HOWTO_HTML = render_howto_html() @@ -1776,6 +2150,13 @@ def __init__(self, t: dict, parent=None): super().__init__(t, render_roi_reference_html(t), parent) +class _MeasurementsPanel(_HtmlReferencePanel): + """Scrollable reference panel describing the image measurements.""" + + def __init__(self, t: dict, parent=None): + super().__init__(t, render_measurements_html(t), parent) + + class _HowToPanel(_HtmlReferencePanel): """Scrollable panel with step-by-step how-to walkthroughs.""" @@ -1796,15 +2177,17 @@ def __init__(self, t: dict, parent=None, *, initial_tab: str = "processing"): self._tabs = QTabWidget(self) self._howto_panel = _HowToPanel(t, self) self._panel = _DefinitionsPanel(t, self) + self._measurements_panel = _MeasurementsPanel(t, self) self._roi_panel = _ROIReferencePanel(t, self) self._tabs.addTab(self._howto_panel, "How-to") self._tabs.addTab(self._panel, "Processing") + self._tabs.addTab(self._measurements_panel, "Measurements") self._tabs.addTab(self._roi_panel, "ROI Actions") lay.addWidget(self._tabs) self.set_reference_tab(initial_tab) # Stable tab keys -> tab index (How-to first as the friendliest landing). - _TAB_INDEX = {"howto": 0, "processing": 1, "roi": 2} + _TAB_INDEX = {"howto": 0, "processing": 1, "measurements": 2, "roi": 3} def set_reference_tab(self, tab: str) -> None: """Switch to the named reference tab.""" @@ -1813,6 +2196,8 @@ def set_reference_tab(self, tab: str) -> None: key = "roi" elif key in {"howto", "how_to", "guides", "guide"}: key = "howto" + elif key in {"measurements", "measurement", "measure"}: + key = "measurements" else: key = "processing" self._tabs.setCurrentIndex(self._TAB_INDEX[key]) diff --git a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py index e3df784..80c8c9d 100644 --- a/probeflow/gui/dialogs/image_viewer_chrome_mixin.py +++ b/probeflow/gui/dialogs/image_viewer_chrome_mixin.py @@ -506,6 +506,11 @@ def _build_viewer_menu_bar(self) -> None: self._show_viewer_definitions, ) help_menu.insertAction(github_action, definitions_action) + measurements_help_action = self._viewer_action( + "help.measurements", + self._show_viewer_measurements, + ) + help_menu.insertAction(github_action, measurements_help_action) roi_reference_help_action = self._viewer_action( "help.roi_reference", self._show_viewer_roi_reference, diff --git a/probeflow/gui/viewer/image_viewer_toolbar_mixin.py b/probeflow/gui/viewer/image_viewer_toolbar_mixin.py index 1f013e7..2a7ba66 100644 --- a/probeflow/gui/viewer/image_viewer_toolbar_mixin.py +++ b/probeflow/gui/viewer/image_viewer_toolbar_mixin.py @@ -39,6 +39,17 @@ def _show_viewer_definitions(self) -> None: dlg.raise_() dlg.activateWindow() + def _show_viewer_measurements(self) -> None: + dlg = getattr(self, "_definitions_dialog", None) + if dlg is None: + dlg = _DefinitionsDialog(self._t, self, initial_tab="measurements") + self._definitions_dialog = dlg + else: + dlg.set_reference_tab("measurements") + dlg.show() + dlg.raise_() + dlg.activateWindow() + def _show_viewer_roi_reference(self) -> None: dlg = getattr(self, "_definitions_dialog", None) if dlg is None: diff --git a/probeflow/gui/viewer/shortcuts.py b/probeflow/gui/viewer/shortcuts.py index c074a93..99458ef 100644 --- a/probeflow/gui/viewer/shortcuts.py +++ b/probeflow/gui/viewer/shortcuts.py @@ -133,6 +133,11 @@ class ViewerCommand: ViewerCommand("help.shortcuts", "Image viewer shortcuts", "Help", status_tip="Show image-viewer shortcut help.", aliases=("keyboard", "keys")), ViewerCommand("help.howto", "How-to guides", "Help", status_tip="Show step-by-step how-to walkthroughs for common tasks.", aliases=("tutorial", "guide", "walkthrough", "getting started")), ViewerCommand("help.definitions", "Definitions", "Help", status_tip="Show processing definitions and equations.", aliases=("reference", "math", "algorithms")), + ViewerCommand( + "help.measurements", "Measurements Reference", "Help", + status_tip="Show what each measurement computes (distance, step height, roughness, …).", + aliases=("measure", "measurement", "stats", "step height", "roughness"), + ), ViewerCommand( "help.roi_reference", "ROI Reference", "Help", status_tip="Show ROI actions, selection rules, and tool interactions.", diff --git a/tests/test_definitions_dialog.py b/tests/test_definitions_dialog.py index 07a19f8..2d91cda 100644 --- a/tests/test_definitions_dialog.py +++ b/tests/test_definitions_dialog.py @@ -120,6 +120,37 @@ def test_howto_reference_has_numbered_steps_and_key_workflows(): assert expected in html, expected +def test_measurements_reference_has_entries_and_formulas(): + from probeflow.gui.dialogs.definitions import ( + _MEASUREMENT_ENTRIES, + render_measurements_html, + ) + from probeflow.gui.styling import THEMES + + html = render_measurements_html(THEMES["light"]) + + # Every measurement entry renders an equation block and an "In practice" lead. + assert html.count('class="equation"') >= len(_MEASUREMENT_ENTRIES) + assert html.count("In practice:") >= len(_MEASUREMENT_ENTRIES) + + for expected in ( + "Measurements Reference", + "Distance", + "Angle", + "Line profile", + "Line periodicity", + "ROI statistics", + "Step height", + "Feature maxima", + "Pair correlation", + "Feature → lattice", + # A couple of formulas must match the implementation. + "rms_roughness = sqrt(mean((z - mean(z))^2))", + "height_difference = mean_b - mean_a", + ): + assert expected in html, expected + + def test_definitions_dialog_tabs_can_focus_howto_processing_and_roi(qapp): from probeflow.gui.dialogs.definitions import _DefinitionsDialog from probeflow.gui.styling import THEMES @@ -127,18 +158,22 @@ def test_definitions_dialog_tabs_can_focus_howto_processing_and_roi(qapp): default = _DefinitionsDialog(THEMES["light"]) roi_first = _DefinitionsDialog(THEMES["light"], initial_tab="roi") howto_first = _DefinitionsDialog(THEMES["light"], initial_tab="howto") + measure_first = _DefinitionsDialog(THEMES["light"], initial_tab="measurements") try: assert default.current_reference_tab() == "processing" default.set_reference_tab("roi") assert default.current_reference_tab() == "roi" + default.set_reference_tab("measurements") + assert default.current_reference_tab() == "measurements" default.set_reference_tab("howto") assert default.current_reference_tab() == "howto" default.set_reference_tab("processing") assert default.current_reference_tab() == "processing" assert roi_first.current_reference_tab() == "roi" assert howto_first.current_reference_tab() == "howto" + assert measure_first.current_reference_tab() == "measurements" finally: - for dlg in (default, roi_first, howto_first): + for dlg in (default, roi_first, howto_first, measure_first): dlg.close() dlg.deleteLater() qapp.processEvents() diff --git a/tests/test_viewer_shortcuts.py b/tests/test_viewer_shortcuts.py index b94fa54..e53a652 100644 --- a/tests/test_viewer_shortcuts.py +++ b/tests/test_viewer_shortcuts.py @@ -54,6 +54,7 @@ def test_command_finder_shortcut_and_visible_commands_are_high_level(): assert "fft.periodic_filter" in finder_ids assert "measure.clear_lattice_grid" in finder_ids assert "help.definitions" in finder_ids + assert "help.measurements" in finder_ids assert "help.roi_reference" in finder_ids assert viewer_command("help.roi_reference").shortcuts == () assert not any(command_id.startswith("roi.tool.") for command_id in finder_ids) From 04203db7532a559f8fe6c7c5d4cefb1bcf7de8f0 Mon Sep 17 00:00:00 2001 From: Peter Jacobson Date: Mon, 15 Jun 2026 22:21:08 +1000 Subject: [PATCH 3/3] Definitions: add Advanced Edge Detection entry; cross-link the line tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the Measurements reference, addressing two review notes: - Advanced Edge Detection was undocumented. Add a Processing-reference entry "Advanced edge detection (Canny / Sobel–Scharr)" describing the Process-tab tool: Canny (Gaussian smoothing, non-maximum suppression, hysteresis with percentile/absolute low/high thresholds) and Sobel/Scharr gradient (magnitude / x / y / orientation, optional threshold-to-mask), plus the overlay / new-image / mask / ROI outputs and presets. Equations read from probeflow/processing/edge_detection.py. - The line tool is both a measurement and an ROI. Cross-reference it both ways: the Measurements "Line profile (and Δ)" entry points to "Line ROI actions" (ROI Actions tab) for drawing/editing/width and notes Distance/Angle also use line ROIs; "Line ROI actions" points to the Measurements tab for the readouts. Tests extended: assert the Advanced edge detection entry (hysteresis, Sobel/Scharr) and the line-tool cross-reference render. Co-Authored-By: Claude Opus 4.8 --- probeflow/gui/dialogs/definitions.py | 63 ++++++++++++++++++++++++++++ tests/test_definitions_dialog.py | 6 +++ 2 files changed, 69 insertions(+) diff --git a/probeflow/gui/dialogs/definitions.py b/probeflow/gui/dialogs/definitions.py index 560b04d..cbe9f7b 100644 --- a/probeflow/gui/dialogs/definitions.py +++ b/probeflow/gui/dialogs/definitions.py @@ -516,6 +516,63 @@ class _HowToEntry: "or DoG and increase the smoothing scale.", ), ), + _DefinitionEntry( + title="Advanced edge detection (Canny / Sobel–Scharr)", + params=( + "method = Canny | Sobel/Scharr", + "sigma", + "low / high threshold (percentile or absolute)", + "preset", + "output = overlay | new image | mask | ROI(s)", + ), + summary=( + "A dedicated edge-finding tool (opened from 'Advanced Edge " + "Detection…' on the Process tab) that turns edges into something you " + "can act on — a clean outline, a mask, or ROIs — rather than just a " + "picture. 'Canny' traces thin, connected edge lines; 'Sobel/Scharr' " + "gives a continuous gradient (how steep the surface is at each " + "pixel). Use it to outline islands, grains, or step edges and feed " + "them to the mask/ROI tools." + ), + in_practice=( + "Pick a Canny preset (e.g. 'Step edges / islands'), watch the live " + "preview, then send the result to a mask or ROIs with the output " + "buttons. Raise 'sigma' on noisy scans; raise the thresholds to keep " + "only the strongest edges." + ), + equations=( + "Canny (skimage):\n" + " 1. Gaussian-smooth the image with sigma (in px)\n" + " 2. gradient magnitude + non-maximum suppression -> thin ridges\n" + " 3. hysteresis: keep ridge pixels >= high threshold (strong) and\n" + " pixels >= low threshold that connect to a strong edge\n" + " thresholds are percentiles of the gradient magnitude inside the\n" + " valid region (or absolute values) -> boolean edge mask\n\n" + "Sobel / Scharr:\n" + " gx, gy = Sobel|Scharr derivative kernels\n" + " magnitude = sqrt(gx^2 + gy^2) (or x, y, or orientation atan2(gy, gx))\n" + " optional: mask = magnitude >= percentile(magnitude, threshold)", + ), + details=( + "This is the analysis cousin of the 'Edge detection' display filter " + "above: instead of replacing the image, it produces a boolean edge " + "map you can overlay, open as a new image, store as the active mask " + "layer, or convert to ROIs for measuring. Canny's two thresholds give " + "hysteresis — a high bar to start an edge and a lower bar to continue " + "it — which traces faint but real boundaries without lighting up " + "noise. Percentile thresholds are the robust default because they " + "adapt to each channel's units.", + "Restricting the detector to an ROI computes its thresholds from " + "inside that region only, so background pixels outside do not dilute " + "the statistics.", + ), + cautions=( + "Edge maps are a derived overlay, not height data — measure on the " + "image, not the edge picture. Too small a sigma or too low a threshold " + "fragments edges and picks up noise; too large merges or misses them. " + "Tune against the preview.", + ), + ), _DefinitionEntry( title="Manual zero reference", params=("set_zero_point", "set_zero_plane_points", "patch"), @@ -1040,6 +1097,9 @@ class _HowToEntry: "repeat spacing (periodicity) from the profile, or set the line width. " "The ruler/distance tool reports the line's true physical length using " "the scan calibration.", + "The measurements a line produces — Line profile (and Δ), Distance, " + "Angle, and Line periodicity — are described in full in the " + "Measurements tab.", ), cautions=( "A line is not an area, so area-only actions — region statistics, " @@ -1226,6 +1286,9 @@ class _HowToEntry: "line, which smooths a noisy profile while keeping the same length " "axis. The length axis is calibrated, so spacings read directly in " "nanometres.", + "The line itself is a line ROI: how to draw it, move its endpoints, " + "and set its averaging width is covered under 'Line ROI actions' in " + "the ROI Actions tab. Distance and Angle (above) also use line ROIs.", ), cautions=( "Averaging over a wide swath blurs sloped or curved features — keep the " diff --git a/tests/test_definitions_dialog.py b/tests/test_definitions_dialog.py index 2d91cda..b894b9f 100644 --- a/tests/test_definitions_dialog.py +++ b/tests/test_definitions_dialog.py @@ -38,6 +38,7 @@ def test_definitions_reference_has_equations_and_light_theme_contrast(): "STM background subtraction", "Gaussian high-pass", "Periodic notch filtering", + "Advanced edge detection (Canny", "Manual zero reference", "Image arithmetic", "Thresholding and bit-depth conversion", @@ -46,6 +47,9 @@ def test_definitions_reference_has_equations_and_light_theme_contrast(): "Forward/backward scan blending", ): assert operation in html + # Advanced edge detection explains hysteresis and the mask/ROI outputs. + assert "hysteresis" in html + assert "Sobel / Scharr" in html assert "color: #111827" in html assert "#cdd6f4" not in lowered @@ -147,6 +151,8 @@ def test_measurements_reference_has_entries_and_formulas(): # A couple of formulas must match the implementation. "rms_roughness = sqrt(mean((z - mean(z))^2))", "height_difference = mean_b - mean_a", + # The line tool's drawing/editing is cross-referenced to the ROI tab. + "Line ROI actions", ): assert expected in html, expected