diff --git a/backend/nodes/multi_profile.py b/backend/nodes/multi_profile.py index 77a6763..c000f23 100644 --- a/backend/nodes/multi_profile.py +++ b/backend/nodes/multi_profile.py @@ -5,7 +5,23 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import DataField, LineData +from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview +from backend.execution_context import emit_overlay + + +def _blend_fields(field_a: DataField, field_b: DataField, alpha: float) -> np.ndarray: + """Render field A with field B overlaid at `alpha` opacity (0=A only, 1=B only).""" + a_rgb = datafield_to_uint8(field_a, field_a.colormap).astype(np.float32) + b_rgb = datafield_to_uint8(field_b, field_b.colormap).astype(np.float32) + wa = 1.0 - alpha + wb = alpha + if b_rgb.shape != a_rgb.shape: + h = min(a_rgb.shape[0], b_rgb.shape[0]) + w = min(a_rgb.shape[1], b_rgb.shape[1]) + canvas = a_rgb.copy() + canvas[:h, :w] = wa * a_rgb[:h, :w] + wb * b_rgb[:h, :w] + return canvas.astype(np.uint8) + return (wa * a_rgb + wb * b_rgb).astype(np.uint8) @register_node(display_name="Multiple Profiles") @@ -19,11 +35,12 @@ class MultipleProfiles: "row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}), "direction": (["horizontal", "vertical"], {"default": "horizontal"}), "mode": (["overlay", "mean", "difference"], {"default": "overlay"}), + "blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "slider": True}), } } OUTPUTS = ( - ('LINE_DATA', 'profile'), + ('LINE', 'profile'), ) FUNCTION = "process" @@ -31,12 +48,14 @@ class MultipleProfiles: "Extract and compare line profiles from two fields. " "Row=-1 uses the center row/column. Modes: overlay returns field_a's " "profile, mean averages both, difference subtracts b from a. " + "The preview shows field A blended with field B and highlights the " + "row or column being sampled — drag to move the line." ) KEYWORDS = ("line profile", "compare", "overlay", "cross section") def process(self, field_a: DataField, field_b: DataField, - row: int, direction: str, mode: str) -> tuple: + row: int, direction: str, mode: str, blend: float = 0.5) -> tuple: a = np.asarray(field_a.data, dtype=np.float64) b = np.asarray(field_b.data, dtype=np.float64) @@ -49,6 +68,7 @@ class MultipleProfiles: pa = pa[:len(pb)] dx = field_a.dx x_unit = field_a.si_unit_xy + line_axis_max = a.shape[0] - 1 else: if row < 0: row = a.shape[1] // 2 @@ -58,6 +78,7 @@ class MultipleProfiles: pa = pa[:len(pb)] dx = field_a.dy x_unit = field_a.si_unit_xy + line_axis_max = a.shape[1] - 1 x_axis = np.arange(len(pa)) * dx @@ -70,5 +91,15 @@ class MultipleProfiles: else: result = pa + alpha = float(np.clip(blend, 0.0, 1.0)) + emit_overlay({ + "kind": "multi_profile", + "section_title": "Preview", + "image": encode_preview(_blend_fields(field_a, field_b, alpha)), + "row": int(row), + "direction": direction, + "max_index": int(line_axis_max), + }) + return (LineData(data=result, x_axis=x_axis, x_unit=x_unit, y_unit=field_a.si_unit_z),) diff --git a/docs/nodes/Multiple Profiles.md b/docs/nodes/Multiple Profiles.md index 84a905e..388b2ae 100644 --- a/docs/nodes/Multiple Profiles.md +++ b/docs/nodes/Multiple Profiles.md @@ -1,6 +1,6 @@ # Multiple Profiles -Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. Equivalent to Gwyddion's multiprofile.c module. +Extract and compare line profiles from two fields along a chosen row or column. Supports overlay, mean, and difference modes. ## Inputs @@ -13,7 +13,7 @@ Extract and compare line profiles from two fields along a chosen row or column. | Name | Type | Description | |------|------|-------------| -| profile | LINE_DATA | Resulting line profile | +| profile | LINE | Resulting line profile | ## Controls @@ -22,6 +22,11 @@ Extract and compare line profiles from two fields along a chosen row or column. | row | INT | -1 | Row (horizontal) or column (vertical) index to extract; -1 uses the centre row/column (-1-10000) | | direction | dropdown | horizontal | Profile direction: horizontal (extract a row) or vertical (extract a column) | | mode | dropdown | overlay | Combination mode: overlay (field_a profile only), mean (average of both), or difference (field_a minus field_b) | +| blend | FLOAT | 0.5 | Opacity of field B in the preview (0 = only A, 1 = only B). Affects the preview image only, not the extracted profile. | + +## Interactive preview + +The preview shows field A blended with field B and highlights the row or column being sampled. Click or drag on the image to move the line; switch between row and column extraction with the `direction` control. ## Notes diff --git a/frontend/src/CustomNode.tsx b/frontend/src/CustomNode.tsx index e07710f..0c8860f 100644 --- a/frontend/src/CustomNode.tsx +++ b/frontend/src/CustomNode.tsx @@ -14,6 +14,7 @@ const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay')); const ThresholdHistogram = lazy(() => import('./ThresholdHistogram')); const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay')); const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay')); +const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay')); import TextNoteNode from './TextNoteNode'; @@ -1198,6 +1199,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { || data.overlay.kind === 'threshold_histogram' || data.overlay.kind === 'radial_profile' || data.overlay.kind === 'straighten_path' + || data.overlay.kind === 'multi_profile' ); const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup'; const overlayTitle = data.overlay?.section_title @@ -1217,6 +1219,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { ? 'Radial Profile' : data.overlay?.kind === 'straighten_path' ? 'Path' + : data.overlay?.kind === 'multi_profile' + ? 'Preview' : 'Cross Section'); const headerMeta = (() => { if (data.className === 'Folder') { @@ -1572,6 +1576,15 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) { nodeId={id} onWidgetChange={ctx!.onWidgetChange} /> + ) : data.overlay!.kind === 'multi_profile' ? ( + ) : data.overlay!.kind === 'angle_measure' ? ( void; +} + +export default function MultiProfileOverlay({ + image, row, direction, maxIndex, + nodeId, onWidgetChange, +}: MultiProfileOverlayProps) { + const containerRef = useRef(null); + const [draftRow, setDraftRow] = useState(null); + const draggingRef = useRef(false); + const pendingCommitRef = useRef(null); + + useEffect(() => { + if (pendingCommitRef.current !== null && row === pendingCommitRef.current) { + pendingCommitRef.current = null; + setDraftRow(null); + } + }, [row]); + + const displayRow = draftRow ?? row; + + const fractionFromEvent = useCallback((e: React.PointerEvent): number => { + if (!containerRef.current) return 0; + const { fx, fy } = pointerToFraction(e, containerRef.current); + return direction === 'horizontal' ? fy : fx; + }, [direction]); + + const fractionToIndex = useCallback((frac: number): number => { + return clamp(Math.round(frac * maxIndex), 0, maxIndex); + }, [maxIndex]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + draggingRef.current = true; + setDraftRow(fractionToIndex(fractionFromEvent(e))); + }, [fractionFromEvent, fractionToIndex]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!draggingRef.current) return; + setDraftRow(fractionToIndex(fractionFromEvent(e))); + }, [fractionFromEvent, fractionToIndex]); + + const onPointerUp = useCallback(() => { + if (draggingRef.current && draftRow !== null) { + pendingCommitRef.current = draftRow; + onWidgetChange(nodeId, 'row', draftRow); + } + draggingRef.current = false; + }, [draftRow, nodeId, onWidgetChange]); + + const fracPos = maxIndex > 0 ? displayRow / maxIndex : 0; + const linePct = clamp(fracPos * 100, 0, 100); + + const lineStyle: React.CSSProperties = direction === 'horizontal' + ? { left: 0, right: 0, top: `${linePct}%`, height: 0 } + : { top: 0, bottom: 0, left: `${linePct}%`, width: 0 }; + + const cursorClass = direction === 'horizontal' ? 'cursor-row' : 'cursor-col'; + + return ( +
+ A blended with B +
+
+ {direction === 'horizontal' ? 'row' : 'col'} {displayRow} +
+
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 27d0f67..c49e75b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1736,6 +1736,43 @@ html, body, #root { transform: translate(-50%, -50%) scale(1.2); } +/* ── Multi Profile overlay ────────────────────────────────────────── */ +.multiprofile-overlay { + position: relative; + user-select: none; + touch-action: none; + overflow: hidden; +} +.multiprofile-overlay.cursor-row { cursor: row-resize; } +.multiprofile-overlay.cursor-col { cursor: col-resize; } +.multiprofile-image { + width: 100%; + display: block; +} +.multiprofile-line { + position: absolute; + pointer-events: none; +} +.multiprofile-line-horizontal { + border-top: 1.5px solid var(--marker); + box-shadow: 0 0 4px var(--marker-shadow); +} +.multiprofile-line-vertical { + border-left: 1.5px solid var(--marker); + box-shadow: 0 0 4px var(--marker-shadow); +} +.multiprofile-readout { + position: absolute; + top: 4px; + left: 4px; + font-size: 10px; + background: rgba(0, 0, 0, 0.55); + color: #fff; + padding: 2px 5px; + border-radius: 3px; + pointer-events: none; +} + .angle-overlay { --angle-line-color: #ff9800; --angle-arc-color: rgb(255, 166, 77); @@ -1992,7 +2029,8 @@ html, body, #root { .is-panning .mask-paint-overlay, .is-panning .markup-overlay, .is-panning .radial-overlay, -.is-panning .straighten-overlay { +.is-panning .straighten-overlay, +.is-panning .multiprofile-overlay { pointer-events: none; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d14e3af..a6bed64 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -79,6 +79,9 @@ export interface OverlayData { thickness?: number; xres?: number; yres?: number; + row?: number; + direction?: 'horizontal' | 'vertical'; + max_index?: number; section_title?: string; line?: number[]; shape?: string; diff --git a/frontend/src/workflowCapture.ts b/frontend/src/workflowCapture.ts index 5c40bd0..9eeaafa 100644 --- a/frontend/src/workflowCapture.ts +++ b/frontend/src/workflowCapture.ts @@ -13,6 +13,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [ '.angle-overlay', // AngleMeasureOverlay '.radial-overlay', // RadialProfileOverlay '.straighten-overlay', // StraightenPathOverlay + '.multiprofile-overlay', // MultiProfileOverlay ]; function encodeBase64(bytes: Uint8Array) { diff --git a/tests/node_tests/multi_profile.py b/tests/node_tests/multi_profile.py index b56e916..52b1cff 100644 --- a/tests/node_tests/multi_profile.py +++ b/tests/node_tests/multi_profile.py @@ -31,3 +31,41 @@ def test_vertical_direction(): field = make_field(shape=(80, 40)) (profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay") assert len(profile.data) == 80, f"Vertical profile length should be field height (80), got {len(profile.data)}" + + +def test_emits_blended_overlay(): + from backend.execution_context import active_node, execution_callbacks + from backend.nodes.multi_profile import MultipleProfiles + + node = MultipleProfiles() + field = make_field(shape=(64, 128)) + + overlays = [] + with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"): + node.process(field, field, row=10, direction="horizontal", mode="overlay") + + assert len(overlays) == 1 + ov = overlays[0] + assert ov["kind"] == "multi_profile" + assert ov["section_title"] == "Preview" + assert ov["image"].startswith("data:image/png;base64,") + assert ov["row"] == 10 + assert ov["direction"] == "horizontal" + assert ov["max_index"] == 63 # height - 1 + + +def test_overlay_max_index_for_vertical(): + from backend.execution_context import active_node, execution_callbacks + from backend.nodes.multi_profile import MultipleProfiles + + node = MultipleProfiles() + field = make_field(shape=(80, 40)) + + overlays = [] + with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"): + node.process(field, field, row=-1, direction="vertical", mode="overlay") + + ov = overlays[0] + assert ov["direction"] == "vertical" + assert ov["max_index"] == 39 # width - 1 + assert ov["row"] == 20 # center column for 40 wide