From 0bf001c24bb1ffc736283be457802a3c0babd476 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Wed, 15 Apr 2026 23:01:47 -0700 Subject: [PATCH] add radial profile --- backend/nodes/radial_profile.py | 57 +++++++++--- frontend/src/CustomNode.tsx | 14 +++ frontend/src/RadialProfileOverlay.tsx | 125 ++++++++++++++++++++++++++ frontend/src/styles.css | 59 +++++++++++- frontend/src/types.ts | 4 + frontend/src/workflowCapture.ts | 1 + 6 files changed, 246 insertions(+), 14 deletions(-) create mode 100644 frontend/src/RadialProfileOverlay.tsx diff --git a/backend/nodes/radial_profile.py b/backend/nodes/radial_profile.py index f3cc812..527cd25 100644 --- a/backend/nodes/radial_profile.py +++ b/backend/nodes/radial_profile.py @@ -2,8 +2,14 @@ from __future__ import annotations import numpy as np +from backend.data_types import ( + DataField, + LineData, + encode_preview, + render_datafield_preview, +) +from backend.execution_context import emit_overlay from backend.node_registry import register_node -from backend.data_types import DataField, LineData @register_node(display_name="Radial Profile") @@ -13,9 +19,11 @@ class RadialProfile: return { "required": { "field": ("DATA_FIELD",), - "cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), - "cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}), "n_bins": ("INT", {"default": 128, "min": 4, "max": 4096, "step": 1}), + "cx": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "cy": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "ex": ("FLOAT", {"default": 0.9, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "ey": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), } } @@ -26,20 +34,34 @@ class RadialProfile: DESCRIPTION = ( "Compute the azimuthally averaged radial profile from a centre point. " - "cx/cy give the centre as a fraction of the field width/height (0.5 = centre). " + "Drag the centre marker on the preview to reposition the profile, " + "drag either end marker to change the radius. " "Output x-axis is radius in physical xy units. " ) KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic") - def process(self, field: DataField, cx: float, cy: float, n_bins: int) -> tuple: + def process( + self, + field: DataField, + n_bins: int, + cx: float, + cy: float, + ex: float, + ey: float, + ) -> tuple: yres, xres = field.data.shape - # Centre in physical coordinates (matches Gwyddion: xc = cx*xreal + xoff) + cx = float(np.clip(cx, 0.0, 1.0)) + cy = float(np.clip(cy, 0.0, 1.0)) + ex = float(np.clip(ex, 0.0, 1.0)) + ey = float(np.clip(ey, 0.0, 1.0)) + xc_phys = cx * field.xreal + field.xoff yc_phys = cy * field.yreal + field.yoff + xe_phys = ex * field.xreal + field.xoff + ye_phys = ey * field.yreal + field.yoff - # Pixel-centre physical coordinates xs = (np.arange(xres) + 0.5) * field.dx + field.xoff ys = (np.arange(yres) + 0.5) * field.dy + field.yoff gx, gy = np.meshgrid(xs, ys) @@ -47,20 +69,19 @@ class RadialProfile: r = np.hypot(gx - xc_phys, gy - yc_phys).ravel() values = field.data.ravel() - # Maximum radius — farthest pixel from centre - r_max = float(r.max()) - if r_max == 0.0: + r_max = float(np.hypot(xe_phys - xc_phys, ye_phys - yc_phys)) + if r_max <= 0.0: r_max = max(field.dx, field.dy) - # Bin by radius — matches Gwyddion's lineres-bin approach bin_edges = np.linspace(0.0, r_max, n_bins + 1) + mask = r <= r_max idx = np.clip( - np.floor(n_bins * r / r_max).astype(np.intp), 0, n_bins - 1 + np.floor(n_bins * r[mask] / r_max).astype(np.intp), 0, n_bins - 1 ) sums = np.zeros(n_bins) counts = np.zeros(n_bins, dtype=np.intp) - np.add.at(sums, idx, values) + np.add.at(sums, idx, values[mask]) np.add.at(counts, idx, 1) with np.errstate(invalid="ignore"): @@ -68,6 +89,16 @@ class RadialProfile: centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) + emit_overlay({ + "kind": "radial_profile", + "section_title": "Radial Profile", + "image": encode_preview(render_datafield_preview(field, field.colormap)), + "cx": cx, + "cy": cy, + "ex": ex, + "ey": ey, + }) + return (LineData( data=profile, x_axis=centers, diff --git a/frontend/src/CustomNode.tsx b/frontend/src/CustomNode.tsx index e714b8a..63d09e9 100644 --- a/frontend/src/CustomNode.tsx +++ b/frontend/src/CustomNode.tsx @@ -12,6 +12,7 @@ const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay')); const MarkupOverlay = lazy(() => import('./MarkupOverlay')); const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay')); const ThresholdHistogram = lazy(() => import('./ThresholdHistogram')); +const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay')); import TextNoteNode from './TextNoteNode'; @@ -1194,6 +1195,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { || data.overlay.kind === 'mask_paint' || data.overlay.kind === 'markup' || data.overlay.kind === 'threshold_histogram' + || data.overlay.kind === 'radial_profile' ); const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup'; const overlayTitle = data.overlay?.section_title @@ -1209,6 +1211,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { ? 'Cursors' : data.overlay?.kind === 'line_plot' ? 'Line Plot' + : data.overlay?.kind === 'radial_profile' + ? 'Radial Profile' : 'Cross Section'); const headerMeta = (() => { if (data.className === 'Folder') { @@ -1541,6 +1545,16 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { nodeId={id} onWidgetChange={ctx!.onWidgetChange} /> + ) : data.overlay!.kind === 'radial_profile' ? ( + ) : data.overlay!.kind === 'angle_measure' ? ( void; +} + +type DragHandle = 'center' | 'a' | 'b'; + +interface DragState { + handle: DragHandle; + start: { fx: number; fy: number }; + points: { cx: number; cy: number; ex: number; ey: number }; +} + +const round3 = (v: number) => parseFloat(v.toFixed(3)); + +export default function RadialProfileOverlay({ + image, cx, cy, ex, ey, + nodeId, onWidgetChange, +}: RadialProfileOverlayProps) { + const containerRef = useRef(null); + const [dragging, setDragging] = useState(null); + + const getCoords = useCallback((e: React.PointerEvent) => { + return pointerToFraction(e, containerRef.current!); + }, []); + + const updateWidgets = useCallback((updates: Record) => { + for (const [name, value] of Object.entries(updates)) { + onWidgetChange(nodeId, name, value); + } + }, [nodeId, onWidgetChange]); + + const onPointerDown = useCallback((handle: DragHandle) => (e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + const start = getCoords(e); + setDragging({ handle, start, points: { cx, cy, ex, ey } }); + }, [cx, cy, ex, ey, getCoords]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragging || !containerRef.current) return; + const { fx, fy } = getCoords(e); + const pts = dragging.points; + + if (dragging.handle === 'center') { + const dx = fx - dragging.start.fx; + const dy = fy - dragging.start.fy; + updateWidgets({ + cx: round3(clampFraction(pts.cx + dx)), + cy: round3(clampFraction(pts.cy + dy)), + ex: round3(clampFraction(pts.ex + dx)), + ey: round3(clampFraction(pts.ey + dy)), + }); + } else if (dragging.handle === 'a') { + updateWidgets({ ex: round3(fx), ey: round3(fy) }); + } else { + updateWidgets({ + ex: round3(clampFraction(2 * pts.cx - fx)), + ey: round3(clampFraction(2 * pts.cy - fy)), + }); + } + }, [dragging, getCoords, updateWidgets]); + + const onPointerUp = useCallback(() => { + setDragging(null); + }, []); + + const bx = 2 * cx - ex; + const by = 2 * cy - ey; + + const rxFrac = Math.abs(ex - cx); + const ryFrac = Math.abs(ey - cy); + + return ( +
+ field + + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ba2d639..c035e0a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1633,6 +1633,62 @@ html, body, #root { opacity: 0.9; } +/* ── Radial profile overlay ───────────────────────────────────────── */ +.radial-overlay { + position: relative; + user-select: none; + touch-action: none; + overflow: hidden; +} +.radial-image { + width: 100%; + display: block; +} +.radial-svg { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; +} +.radial-circle { + fill: none; + stroke: var(--marker); + stroke-width: 1.4; + vector-effect: non-scaling-stroke; + stroke-dasharray: 4 3; +} +.radial-diameter { + stroke: var(--marker); + stroke-width: 1.2; + vector-effect: non-scaling-stroke; + stroke-dasharray: 3 3; + opacity: 0.7; +} +.radial-marker { + position: absolute; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--marker); + border: 1px solid var(--marker-border); + transform: translate(-50%, -50%); + cursor: grab; + box-shadow: 0 0 4px var(--marker-shadow); + z-index: 1; +} +.radial-marker:active { + cursor: grabbing; + background: var(--marker-active); + transform: translate(-50%, -50%) scale(1.2); +} +.radial-marker-center { + width: 10px; + height: 10px; + border-radius: 2px; +} + .angle-overlay { --angle-line-color: #ff9800; --angle-arc-color: rgb(255, 166, 77); @@ -1879,7 +1935,8 @@ html, body, #root { .is-panning .lineplot-overlay, .is-panning .crop-overlay, .is-panning .mask-paint-overlay, -.is-panning .markup-overlay { +.is-panning .markup-overlay, +.is-panning .radial-overlay { pointer-events: none; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a9081bf..08edca4 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -66,6 +66,10 @@ export interface OverlayData { y2?: number; xm?: number; ym?: number; + cx?: number; + cy?: number; + ex?: number; + ey?: number; a_locked?: boolean; b_locked?: boolean; section_title?: string; diff --git a/frontend/src/workflowCapture.ts b/frontend/src/workflowCapture.ts index fbd11ee..3002cfa 100644 --- a/frontend/src/workflowCapture.ts +++ b/frontend/src/workflowCapture.ts @@ -11,6 +11,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [ '.crop-overlay', // CropBoxOverlay '.markup-overlay', // MarkupOverlay '.angle-overlay', // AngleMeasureOverlay + '.radial-overlay', // RadialProfileOverlay ]; function encodeBase64(bytes: Uint8Array) {