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 (
+
+

+
+
+
+
+
+
+
+ );
+}
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) {