add radial profile
This commit is contained in:
@@ -2,8 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import numpy as np
|
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.node_registry import register_node
|
||||||
from backend.data_types import DataField, LineData
|
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Radial Profile")
|
@register_node(display_name="Radial Profile")
|
||||||
@@ -13,9 +19,11 @@ class RadialProfile:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"field": ("DATA_FIELD",),
|
"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}),
|
"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 = (
|
DESCRIPTION = (
|
||||||
"Compute the azimuthally averaged radial profile from a centre point. "
|
"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. "
|
"Output x-axis is radius in physical xy units. "
|
||||||
)
|
)
|
||||||
|
|
||||||
KEYWORDS = ("azimuthal average", "ring average", "circular", "isotropic")
|
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
|
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
|
xc_phys = cx * field.xreal + field.xoff
|
||||||
yc_phys = cy * field.yreal + field.yoff
|
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
|
xs = (np.arange(xres) + 0.5) * field.dx + field.xoff
|
||||||
ys = (np.arange(yres) + 0.5) * field.dy + field.yoff
|
ys = (np.arange(yres) + 0.5) * field.dy + field.yoff
|
||||||
gx, gy = np.meshgrid(xs, ys)
|
gx, gy = np.meshgrid(xs, ys)
|
||||||
@@ -47,20 +69,19 @@ class RadialProfile:
|
|||||||
r = np.hypot(gx - xc_phys, gy - yc_phys).ravel()
|
r = np.hypot(gx - xc_phys, gy - yc_phys).ravel()
|
||||||
values = field.data.ravel()
|
values = field.data.ravel()
|
||||||
|
|
||||||
# Maximum radius — farthest pixel from centre
|
r_max = float(np.hypot(xe_phys - xc_phys, ye_phys - yc_phys))
|
||||||
r_max = float(r.max())
|
if r_max <= 0.0:
|
||||||
if r_max == 0.0:
|
|
||||||
r_max = max(field.dx, field.dy)
|
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)
|
bin_edges = np.linspace(0.0, r_max, n_bins + 1)
|
||||||
|
mask = r <= r_max
|
||||||
idx = np.clip(
|
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)
|
sums = np.zeros(n_bins)
|
||||||
counts = np.zeros(n_bins, dtype=np.intp)
|
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)
|
np.add.at(counts, idx, 1)
|
||||||
|
|
||||||
with np.errstate(invalid="ignore"):
|
with np.errstate(invalid="ignore"):
|
||||||
@@ -68,6 +89,16 @@ class RadialProfile:
|
|||||||
|
|
||||||
centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
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(
|
return (LineData(
|
||||||
data=profile,
|
data=profile,
|
||||||
x_axis=centers,
|
x_axis=centers,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
|||||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||||
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
||||||
|
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
|
||||||
|
|
||||||
import TextNoteNode from './TextNoteNode';
|
import TextNoteNode from './TextNoteNode';
|
||||||
|
|
||||||
@@ -1194,6 +1195,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
|| data.overlay.kind === 'mask_paint'
|
|| data.overlay.kind === 'mask_paint'
|
||||||
|| data.overlay.kind === 'markup'
|
|| data.overlay.kind === 'markup'
|
||||||
|| data.overlay.kind === 'threshold_histogram'
|
|| data.overlay.kind === 'threshold_histogram'
|
||||||
|
|| data.overlay.kind === 'radial_profile'
|
||||||
);
|
);
|
||||||
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
|
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
|
||||||
const overlayTitle = data.overlay?.section_title
|
const overlayTitle = data.overlay?.section_title
|
||||||
@@ -1209,6 +1211,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
? 'Cursors'
|
? 'Cursors'
|
||||||
: data.overlay?.kind === 'line_plot'
|
: data.overlay?.kind === 'line_plot'
|
||||||
? 'Line Plot'
|
? 'Line Plot'
|
||||||
|
: data.overlay?.kind === 'radial_profile'
|
||||||
|
? 'Radial Profile'
|
||||||
: 'Cross Section');
|
: 'Cross Section');
|
||||||
const headerMeta = (() => {
|
const headerMeta = (() => {
|
||||||
if (data.className === 'Folder') {
|
if (data.className === 'Folder') {
|
||||||
@@ -1541,6 +1545,16 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
nodeId={id}
|
nodeId={id}
|
||||||
onWidgetChange={ctx!.onWidgetChange}
|
onWidgetChange={ctx!.onWidgetChange}
|
||||||
/>
|
/>
|
||||||
|
) : data.overlay!.kind === 'radial_profile' ? (
|
||||||
|
<RadialProfileOverlay
|
||||||
|
image={data.overlay!.image ?? ''}
|
||||||
|
cx={(data.widgetValues.cx ?? data.overlay!.cx ?? 0.5) as number}
|
||||||
|
cy={(data.widgetValues.cy ?? data.overlay!.cy ?? 0.5) as number}
|
||||||
|
ex={(data.widgetValues.ex ?? data.overlay!.ex ?? 0.9) as number}
|
||||||
|
ey={(data.widgetValues.ey ?? data.overlay!.ey ?? 0.5) as number}
|
||||||
|
nodeId={id}
|
||||||
|
onWidgetChange={ctx!.onWidgetChange}
|
||||||
|
/>
|
||||||
) : data.overlay!.kind === 'angle_measure' ? (
|
) : data.overlay!.kind === 'angle_measure' ? (
|
||||||
<AngleMeasureOverlay
|
<AngleMeasureOverlay
|
||||||
image={data.overlay!.image ?? ''}
|
image={data.overlay!.image ?? ''}
|
||||||
|
|||||||
125
frontend/src/RadialProfileOverlay.tsx
Normal file
125
frontend/src/RadialProfileOverlay.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
|
export const CAPTURE_SELECTOR = '.radial-overlay';
|
||||||
|
|
||||||
|
interface RadialProfileOverlayProps {
|
||||||
|
image: string;
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
ex: number;
|
||||||
|
ey: number;
|
||||||
|
nodeId: string;
|
||||||
|
onWidgetChange: (nodeId: string, name: string, value: unknown) => 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<HTMLDivElement>(null);
|
||||||
|
const [dragging, setDragging] = useState<DragState | null>(null);
|
||||||
|
|
||||||
|
const getCoords = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
|
return pointerToFraction(e, containerRef.current!);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateWidgets = useCallback((updates: Record<string, number>) => {
|
||||||
|
for (const [name, value] of Object.entries(updates)) {
|
||||||
|
onWidgetChange(nodeId, name, value);
|
||||||
|
}
|
||||||
|
}, [nodeId, onWidgetChange]);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((handle: DragHandle) => (e: React.PointerEvent<Element>) => {
|
||||||
|
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<Element>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="nodrag nowheel radial-overlay"
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
>
|
||||||
|
<img src={image} alt="field" draggable={false} className="radial-image" />
|
||||||
|
|
||||||
|
<svg className="radial-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<ellipse
|
||||||
|
cx={cx * 100} cy={cy * 100}
|
||||||
|
rx={rxFrac * 100} ry={ryFrac * 100}
|
||||||
|
className="radial-circle"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={ex * 100} y1={ey * 100}
|
||||||
|
x2={bx * 100} y2={by * 100}
|
||||||
|
className="radial-diameter"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="radial-marker radial-marker-end"
|
||||||
|
style={{ left: `${ex * 100}%`, top: `${ey * 100}%` }}
|
||||||
|
onPointerDown={onPointerDown('a')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="radial-marker radial-marker-end"
|
||||||
|
style={{ left: `${bx * 100}%`, top: `${by * 100}%` }}
|
||||||
|
onPointerDown={onPointerDown('b')}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="radial-marker radial-marker-center"
|
||||||
|
style={{ left: `${cx * 100}%`, top: `${cy * 100}%` }}
|
||||||
|
onPointerDown={onPointerDown('center')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1633,6 +1633,62 @@ html, body, #root {
|
|||||||
opacity: 0.9;
|
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-overlay {
|
||||||
--angle-line-color: #ff9800;
|
--angle-line-color: #ff9800;
|
||||||
--angle-arc-color: rgb(255, 166, 77);
|
--angle-arc-color: rgb(255, 166, 77);
|
||||||
@@ -1879,7 +1935,8 @@ html, body, #root {
|
|||||||
.is-panning .lineplot-overlay,
|
.is-panning .lineplot-overlay,
|
||||||
.is-panning .crop-overlay,
|
.is-panning .crop-overlay,
|
||||||
.is-panning .mask-paint-overlay,
|
.is-panning .mask-paint-overlay,
|
||||||
.is-panning .markup-overlay {
|
.is-panning .markup-overlay,
|
||||||
|
.is-panning .radial-overlay {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export interface OverlayData {
|
|||||||
y2?: number;
|
y2?: number;
|
||||||
xm?: number;
|
xm?: number;
|
||||||
ym?: number;
|
ym?: number;
|
||||||
|
cx?: number;
|
||||||
|
cy?: number;
|
||||||
|
ex?: number;
|
||||||
|
ey?: number;
|
||||||
a_locked?: boolean;
|
a_locked?: boolean;
|
||||||
b_locked?: boolean;
|
b_locked?: boolean;
|
||||||
section_title?: string;
|
section_title?: string;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
|
|||||||
'.crop-overlay', // CropBoxOverlay
|
'.crop-overlay', // CropBoxOverlay
|
||||||
'.markup-overlay', // MarkupOverlay
|
'.markup-overlay', // MarkupOverlay
|
||||||
'.angle-overlay', // AngleMeasureOverlay
|
'.angle-overlay', // AngleMeasureOverlay
|
||||||
|
'.radial-overlay', // RadialProfileOverlay
|
||||||
];
|
];
|
||||||
|
|
||||||
function encodeBase64(bytes: Uint8Array) {
|
function encodeBase64(bytes: Uint8Array) {
|
||||||
|
|||||||
Reference in New Issue
Block a user