fix multi-profile
This commit is contained in:
@@ -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),)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ?? ''}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user