fix multi-profile

This commit is contained in:
2026-04-16 01:14:57 -07:00
parent 2d66eaef02
commit c7e7531206
8 changed files with 224 additions and 6 deletions

View File

@@ -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),)

View File

@@ -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

View File

@@ -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' ? (
<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' ? (
<AngleMeasureOverlay
image={data.overlay!.image ?? ''}

View 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>
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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