fix multi-profile
This commit is contained in:
@@ -5,7 +5,23 @@ from __future__ import annotations
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from backend.node_registry import register_node
|
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")
|
@register_node(display_name="Multiple Profiles")
|
||||||
@@ -19,11 +35,12 @@ class MultipleProfiles:
|
|||||||
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
|
"row": ("INT", {"default": -1, "min": -1, "max": 10000, "step": 1}),
|
||||||
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
|
"direction": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||||
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
|
"mode": (["overlay", "mean", "difference"], {"default": "overlay"}),
|
||||||
|
"blend": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "slider": True}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OUTPUTS = (
|
OUTPUTS = (
|
||||||
('LINE_DATA', 'profile'),
|
('LINE', 'profile'),
|
||||||
)
|
)
|
||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
@@ -31,12 +48,14 @@ class MultipleProfiles:
|
|||||||
"Extract and compare line profiles from two fields. "
|
"Extract and compare line profiles from two fields. "
|
||||||
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
|
"Row=-1 uses the center row/column. Modes: overlay returns field_a's "
|
||||||
"profile, mean averages both, difference subtracts b from a. "
|
"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")
|
KEYWORDS = ("line profile", "compare", "overlay", "cross section")
|
||||||
|
|
||||||
def process(self, field_a: DataField, field_b: DataField,
|
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)
|
a = np.asarray(field_a.data, dtype=np.float64)
|
||||||
b = np.asarray(field_b.data, dtype=np.float64)
|
b = np.asarray(field_b.data, dtype=np.float64)
|
||||||
|
|
||||||
@@ -49,6 +68,7 @@ class MultipleProfiles:
|
|||||||
pa = pa[:len(pb)]
|
pa = pa[:len(pb)]
|
||||||
dx = field_a.dx
|
dx = field_a.dx
|
||||||
x_unit = field_a.si_unit_xy
|
x_unit = field_a.si_unit_xy
|
||||||
|
line_axis_max = a.shape[0] - 1
|
||||||
else:
|
else:
|
||||||
if row < 0:
|
if row < 0:
|
||||||
row = a.shape[1] // 2
|
row = a.shape[1] // 2
|
||||||
@@ -58,6 +78,7 @@ class MultipleProfiles:
|
|||||||
pa = pa[:len(pb)]
|
pa = pa[:len(pb)]
|
||||||
dx = field_a.dy
|
dx = field_a.dy
|
||||||
x_unit = field_a.si_unit_xy
|
x_unit = field_a.si_unit_xy
|
||||||
|
line_axis_max = a.shape[1] - 1
|
||||||
|
|
||||||
x_axis = np.arange(len(pa)) * dx
|
x_axis = np.arange(len(pa)) * dx
|
||||||
|
|
||||||
@@ -70,5 +91,15 @@ class MultipleProfiles:
|
|||||||
else:
|
else:
|
||||||
result = pa
|
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,
|
return (LineData(data=result, x_axis=x_axis, x_unit=x_unit,
|
||||||
y_unit=field_a.si_unit_z),)
|
y_unit=field_a.si_unit_z),)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Multiple Profiles
|
# 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
|
## Inputs
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Extract and compare line profiles from two fields along a chosen row or column.
|
|||||||
|
|
||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| profile | LINE_DATA | Resulting line profile |
|
| profile | LINE | Resulting line profile |
|
||||||
|
|
||||||
## Controls
|
## 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) |
|
| 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) |
|
| 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) |
|
| 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
|||||||
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
||||||
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
|
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
|
||||||
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
|
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
|
||||||
|
const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay'));
|
||||||
|
|
||||||
import TextNoteNode from './TextNoteNode';
|
import TextNoteNode from './TextNoteNode';
|
||||||
|
|
||||||
@@ -1198,6 +1199,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
|| data.overlay.kind === 'threshold_histogram'
|
|| data.overlay.kind === 'threshold_histogram'
|
||||||
|| data.overlay.kind === 'radial_profile'
|
|| data.overlay.kind === 'radial_profile'
|
||||||
|| data.overlay.kind === 'straighten_path'
|
|| data.overlay.kind === 'straighten_path'
|
||||||
|
|| data.overlay.kind === 'multi_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
|
||||||
@@ -1217,6 +1219,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
? 'Radial Profile'
|
? 'Radial Profile'
|
||||||
: data.overlay?.kind === 'straighten_path'
|
: data.overlay?.kind === 'straighten_path'
|
||||||
? 'Path'
|
? 'Path'
|
||||||
|
: data.overlay?.kind === 'multi_profile'
|
||||||
|
? 'Preview'
|
||||||
: 'Cross Section');
|
: 'Cross Section');
|
||||||
const headerMeta = (() => {
|
const headerMeta = (() => {
|
||||||
if (data.className === 'Folder') {
|
if (data.className === 'Folder') {
|
||||||
@@ -1572,6 +1576,15 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
nodeId={id}
|
nodeId={id}
|
||||||
onWidgetChange={ctx!.onWidgetChange}
|
onWidgetChange={ctx!.onWidgetChange}
|
||||||
/>
|
/>
|
||||||
|
) : data.overlay!.kind === 'multi_profile' ? (
|
||||||
|
<MultiProfileOverlay
|
||||||
|
image={data.overlay!.image ?? ''}
|
||||||
|
row={(data.overlay!.row ?? 0) as number}
|
||||||
|
direction={(data.overlay!.direction ?? 'horizontal') as 'horizontal' | 'vertical'}
|
||||||
|
maxIndex={(data.overlay!.max_index ?? 0) 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 ?? ''}
|
||||||
|
|||||||
89
frontend/src/MultiProfileOverlay.tsx
Normal file
89
frontend/src/MultiProfileOverlay.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { clamp, pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
|
export const CAPTURE_SELECTOR = '.multiprofile-overlay';
|
||||||
|
|
||||||
|
interface MultiProfileOverlayProps {
|
||||||
|
image: string;
|
||||||
|
row: number;
|
||||||
|
direction: 'horizontal' | 'vertical';
|
||||||
|
maxIndex: number;
|
||||||
|
nodeId: string;
|
||||||
|
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MultiProfileOverlay({
|
||||||
|
image, row, direction, maxIndex,
|
||||||
|
nodeId, onWidgetChange,
|
||||||
|
}: MultiProfileOverlayProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draftRow, setDraftRow] = useState<number | null>(null);
|
||||||
|
const draggingRef = useRef(false);
|
||||||
|
const pendingCommitRef = useRef<number | null>(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<Element>): 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<Element>) => {
|
||||||
|
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<Element>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`nodrag nowheel multiprofile-overlay ${cursorClass}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
>
|
||||||
|
<img src={image} alt="A blended with B" draggable={false} className="multiprofile-image" />
|
||||||
|
<div className={`multiprofile-line multiprofile-line-${direction}`} style={lineStyle} />
|
||||||
|
<div className="multiprofile-readout">
|
||||||
|
{direction === 'horizontal' ? 'row' : 'col'} {displayRow}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1736,6 +1736,43 @@ html, body, #root {
|
|||||||
transform: translate(-50%, -50%) scale(1.2);
|
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-overlay {
|
||||||
--angle-line-color: #ff9800;
|
--angle-line-color: #ff9800;
|
||||||
--angle-arc-color: rgb(255, 166, 77);
|
--angle-arc-color: rgb(255, 166, 77);
|
||||||
@@ -1992,7 +2029,8 @@ html, body, #root {
|
|||||||
.is-panning .mask-paint-overlay,
|
.is-panning .mask-paint-overlay,
|
||||||
.is-panning .markup-overlay,
|
.is-panning .markup-overlay,
|
||||||
.is-panning .radial-overlay,
|
.is-panning .radial-overlay,
|
||||||
.is-panning .straighten-overlay {
|
.is-panning .straighten-overlay,
|
||||||
|
.is-panning .multiprofile-overlay {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ export interface OverlayData {
|
|||||||
thickness?: number;
|
thickness?: number;
|
||||||
xres?: number;
|
xres?: number;
|
||||||
yres?: number;
|
yres?: number;
|
||||||
|
row?: number;
|
||||||
|
direction?: 'horizontal' | 'vertical';
|
||||||
|
max_index?: number;
|
||||||
section_title?: string;
|
section_title?: string;
|
||||||
line?: number[];
|
line?: number[];
|
||||||
shape?: string;
|
shape?: string;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
|
|||||||
'.angle-overlay', // AngleMeasureOverlay
|
'.angle-overlay', // AngleMeasureOverlay
|
||||||
'.radial-overlay', // RadialProfileOverlay
|
'.radial-overlay', // RadialProfileOverlay
|
||||||
'.straighten-overlay', // StraightenPathOverlay
|
'.straighten-overlay', // StraightenPathOverlay
|
||||||
|
'.multiprofile-overlay', // MultiProfileOverlay
|
||||||
];
|
];
|
||||||
|
|
||||||
function encodeBase64(bytes: Uint8Array) {
|
function encodeBase64(bytes: Uint8Array) {
|
||||||
|
|||||||
@@ -31,3 +31,41 @@ def test_vertical_direction():
|
|||||||
field = make_field(shape=(80, 40))
|
field = make_field(shape=(80, 40))
|
||||||
(profile,) = node.process(field, field, row=-1, direction="vertical", mode="overlay")
|
(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)}"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user