work on straighten path
This commit is contained in:
@@ -3,10 +3,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
from scipy.interpolate import CubicSpline
|
||||||
from scipy.ndimage import map_coordinates
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
||||||
from backend.node_registry import register_node
|
from backend.node_registry import register_node
|
||||||
from backend.data_types import DataField
|
from backend.data_types import DataField, LineData, datafield_to_uint8, encode_preview
|
||||||
|
from backend.execution_context import emit_overlay
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Straighten Path")
|
@register_node(display_name="Straighten Path")
|
||||||
@@ -16,8 +18,8 @@ class StraightenPath:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"field": ("DATA_FIELD",),
|
"field": ("DATA_FIELD",),
|
||||||
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75"}),
|
"points_x": ("STRING", {"default": "0.25, 0.5, 0.75", "hidden": True}),
|
||||||
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5"}),
|
"points_y": ("STRING", {"default": "0.5, 0.3, 0.5", "hidden": True}),
|
||||||
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
|
"thickness": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
|
||||||
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
|
"n_samples": ("INT", {"default": 256, "min": 10, "max": 2048, "step": 1}),
|
||||||
}
|
}
|
||||||
@@ -25,14 +27,15 @@ class StraightenPath:
|
|||||||
|
|
||||||
OUTPUTS = (
|
OUTPUTS = (
|
||||||
('DATA_FIELD', 'straightened'),
|
('DATA_FIELD', 'straightened'),
|
||||||
|
('LINE', 'profile'),
|
||||||
)
|
)
|
||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Extract a cross-section along an arbitrary curved path defined by "
|
"Extract a cross-section along an arbitrary curved path defined by "
|
||||||
"control points. Points are given as fractional coordinates (0-1). "
|
"control points. The path is a natural cubic spline through the "
|
||||||
"The path is interpolated with cubic splines, and data is sampled "
|
"points. Drag the points on the preview to reshape the path; the "
|
||||||
"along it with configurable thickness. "
|
"shaded band shows the sampling thickness. "
|
||||||
)
|
)
|
||||||
|
|
||||||
KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path")
|
KEYWORDS = ("unbend", "unroll", "spline", "curved profile", "extract path")
|
||||||
@@ -42,36 +45,46 @@ class StraightenPath:
|
|||||||
data = np.asarray(field.data, dtype=np.float64)
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
yres, xres = data.shape
|
yres, xres = data.shape
|
||||||
|
|
||||||
# Parse control points
|
fx = [float(v.strip()) for v in points_x.split(",") if v.strip()]
|
||||||
px = [float(v.strip()) * (xres - 1) for v in points_x.split(",") if v.strip()]
|
fy = [float(v.strip()) for v in points_y.split(",") if v.strip()]
|
||||||
py = [float(v.strip()) * (yres - 1) for v in points_y.split(",") if v.strip()]
|
n_pts = min(len(fx), len(fy))
|
||||||
|
fx, fy = fx[:n_pts], fy[:n_pts]
|
||||||
|
|
||||||
if len(px) < 2 or len(py) < 2:
|
emit_overlay({
|
||||||
# Need at least 2 points
|
"kind": "straighten_path",
|
||||||
return (field,)
|
"section_title": "Path",
|
||||||
|
"image": encode_preview(datafield_to_uint8(field, field.colormap)),
|
||||||
|
"points": [{"x": float(fx[i]), "y": float(fy[i])} for i in range(n_pts)],
|
||||||
|
"thickness": int(thickness),
|
||||||
|
"xres": int(xres),
|
||||||
|
"yres": int(yres),
|
||||||
|
})
|
||||||
|
|
||||||
n_pts = min(len(px), len(py))
|
if n_pts < 2:
|
||||||
px, py = px[:n_pts], py[:n_pts]
|
empty_line = LineData(
|
||||||
|
data=np.zeros(0, dtype=np.float64),
|
||||||
|
x_axis=np.zeros(0, dtype=np.float64),
|
||||||
|
x_unit=field.si_unit_xy,
|
||||||
|
y_unit=field.si_unit_z,
|
||||||
|
)
|
||||||
|
return (field, empty_line)
|
||||||
|
|
||||||
|
px = [f * (xres - 1) for f in fx]
|
||||||
|
py = [f * (yres - 1) for f in fy]
|
||||||
|
|
||||||
# Parameterize path and interpolate
|
|
||||||
t_ctrl = np.linspace(0, 1, n_pts)
|
t_ctrl = np.linspace(0, 1, n_pts)
|
||||||
t_sample = np.linspace(0, 1, n_samples)
|
t_sample = np.linspace(0, 1, n_samples)
|
||||||
|
if n_pts >= 3:
|
||||||
# Simple cubic interpolation via numpy
|
cx = CubicSpline(t_ctrl, px, bc_type="natural")(t_sample)
|
||||||
if n_pts >= 4:
|
cy = CubicSpline(t_ctrl, py, bc_type="natural")(t_sample)
|
||||||
from numpy.polynomial.polynomial import Polynomial
|
|
||||||
cx = np.interp(t_sample, t_ctrl, px)
|
|
||||||
cy = np.interp(t_sample, t_ctrl, py)
|
|
||||||
else:
|
else:
|
||||||
cx = np.interp(t_sample, t_ctrl, px)
|
cx = np.interp(t_sample, t_ctrl, px)
|
||||||
cy = np.interp(t_sample, t_ctrl, py)
|
cy = np.interp(t_sample, t_ctrl, py)
|
||||||
|
|
||||||
# Sample along path with thickness
|
|
||||||
if thickness <= 1:
|
if thickness <= 1:
|
||||||
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||||
result = values.reshape(1, -1)
|
result = values.reshape(1, -1)
|
||||||
else:
|
else:
|
||||||
# Compute normals
|
|
||||||
dcx = np.gradient(cx)
|
dcx = np.gradient(cx)
|
||||||
dcy = np.gradient(cy)
|
dcy = np.gradient(cy)
|
||||||
length = np.sqrt(dcx**2 + dcy**2)
|
length = np.sqrt(dcx**2 + dcy**2)
|
||||||
@@ -86,12 +99,22 @@ class StraightenPath:
|
|||||||
sy = cy + off * ny
|
sy = cy + off * ny
|
||||||
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
result[i] = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||||
|
|
||||||
# Physical dimensions
|
|
||||||
total_length = 0.0
|
total_length = 0.0
|
||||||
for i in range(1, len(cx)):
|
for i in range(1, len(cx)):
|
||||||
dx_phys = (cx[i] - cx[i - 1]) * field.dx
|
dx_phys = (cx[i] - cx[i - 1]) * field.dx
|
||||||
dy_phys = (cy[i] - cy[i - 1]) * field.dy
|
dy_phys = (cy[i] - cy[i - 1]) * field.dy
|
||||||
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
|
total_length += np.sqrt(dx_phys**2 + dy_phys**2)
|
||||||
|
|
||||||
return (field.replace(data=result, xreal=total_length,
|
center_values = map_coordinates(data, [cy, cx], order=1, mode='nearest')
|
||||||
yreal=thickness * max(field.dx, field.dy)),)
|
profile = LineData(
|
||||||
|
data=center_values,
|
||||||
|
x_axis=np.linspace(0.0, total_length, n_samples),
|
||||||
|
x_unit=field.si_unit_xy,
|
||||||
|
y_unit=field.si_unit_z,
|
||||||
|
)
|
||||||
|
|
||||||
|
straightened = field.replace(
|
||||||
|
data=result, xreal=total_length,
|
||||||
|
yreal=thickness * max(field.dx, field.dy),
|
||||||
|
)
|
||||||
|
return (straightened, profile)
|
||||||
|
|||||||
@@ -13,20 +13,25 @@ Extract a cross-section along an arbitrary curved path defined by control points
|
|||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness |
|
| straightened | DATA_FIELD | Straightened cross-section; width = n_samples, height = thickness |
|
||||||
|
| profile | LINE | 1-pixel-wide profile sampled along the centerline of the path |
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
| Name | Type | Default | Description |
|
||||||
|------|------|---------|-------------|
|
|------|------|---------|-------------|
|
||||||
| points_x | STRING | "0.25, 0.5, 0.75" | Comma-separated fractional x-coordinates of control points (0.0-1.0) |
|
|
||||||
| points_y | STRING | "0.5, 0.3, 0.5" | Comma-separated fractional y-coordinates of control points (0.0-1.0) |
|
|
||||||
| thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1-100) |
|
| thickness | INT | 1 | Width of the sampled strip perpendicular to the path, in pixels (1-100) |
|
||||||
| n_samples | INT | 256 | Number of sample points along the path (10-2048) |
|
| n_samples | INT | 256 | Number of sample points along the path (10-2048) |
|
||||||
|
|
||||||
|
## Interactive preview
|
||||||
|
|
||||||
|
The node renders the input field with the control points and a smooth curve through them. Drag any point to reshape the path. Double-click anywhere on the image to add a new point at that location. Shift-click a point to delete it (a minimum of two points is kept). The shaded band along the curve previews the sampling thickness.
|
||||||
|
|
||||||
|
The straightened result is shown in the regular preview section below.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required.
|
- Control points are specified as fractions of the image dimensions (0 = left/top edge, 1 = right/bottom edge). At least 2 points are required.
|
||||||
- Points are connected by linear interpolation; the path is sampled at n_samples evenly spaced positions.
|
- With 3 or more points, the path is a natural cubic spline (C² continuous) passing through each control point, matching the smooth curve drawn on the preview. With exactly 2 points the path is a straight line.
|
||||||
- When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line.
|
- When thickness > 1, samples are taken along the local normal direction at each path position, producing a 2D strip rather than a single line.
|
||||||
- The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size.
|
- The output xreal equals the physical path length (computed from pixel spacing), and yreal equals thickness times the pixel size.
|
||||||
- Bilinear interpolation (order=1) is used with nearest-edge boundary handling.
|
- Bilinear interpolation (order=1) is used with nearest-edge boundary handling.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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'));
|
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
|
||||||
|
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
|
||||||
|
|
||||||
import TextNoteNode from './TextNoteNode';
|
import TextNoteNode from './TextNoteNode';
|
||||||
|
|
||||||
@@ -1196,6 +1197,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
|| data.overlay.kind === 'markup'
|
|| data.overlay.kind === 'markup'
|
||||||
|| 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'
|
||||||
);
|
);
|
||||||
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
|
||||||
@@ -1213,6 +1215,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
? 'Line Plot'
|
? 'Line Plot'
|
||||||
: data.overlay?.kind === 'radial_profile'
|
: data.overlay?.kind === 'radial_profile'
|
||||||
? 'Radial Profile'
|
? 'Radial Profile'
|
||||||
|
: data.overlay?.kind === 'straighten_path'
|
||||||
|
? 'Path'
|
||||||
: 'Cross Section');
|
: 'Cross Section');
|
||||||
const headerMeta = (() => {
|
const headerMeta = (() => {
|
||||||
if (data.className === 'Folder') {
|
if (data.className === 'Folder') {
|
||||||
@@ -1558,6 +1562,16 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
nodeId={id}
|
nodeId={id}
|
||||||
onWidgetChange={ctx!.onWidgetChange}
|
onWidgetChange={ctx!.onWidgetChange}
|
||||||
/>
|
/>
|
||||||
|
) : data.overlay!.kind === 'straighten_path' ? (
|
||||||
|
<StraightenPathOverlay
|
||||||
|
image={data.overlay!.image ?? ''}
|
||||||
|
points={(data.overlay!.points ?? []) as Array<{ x: number; y: number }>}
|
||||||
|
thickness={(data.widgetValues.thickness ?? data.overlay!.thickness ?? 1) as number}
|
||||||
|
xres={(data.overlay!.xres ?? 1) as number}
|
||||||
|
yres={(data.overlay!.yres ?? 1) 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 ?? ''}
|
||||||
|
|||||||
213
frontend/src/StraightenPathOverlay.tsx
Normal file
213
frontend/src/StraightenPathOverlay.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { clampFraction, pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
|
export const CAPTURE_SELECTOR = '.straighten-overlay';
|
||||||
|
|
||||||
|
interface Point { x: number; y: number; }
|
||||||
|
|
||||||
|
interface StraightenPathOverlayProps {
|
||||||
|
image: string;
|
||||||
|
points: Point[];
|
||||||
|
thickness: number;
|
||||||
|
xres: number;
|
||||||
|
yres: number;
|
||||||
|
nodeId: string;
|
||||||
|
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const round3 = (v: number) => parseFloat(v.toFixed(3));
|
||||||
|
|
||||||
|
function pointsToStrings(points: Point[]) {
|
||||||
|
return {
|
||||||
|
points_x: points.map(p => round3(p.x)).join(', '),
|
||||||
|
points_y: points.map(p => round3(p.y)).join(', '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solve a 1-D natural cubic spline (matches scipy.interpolate.CubicSpline with
|
||||||
|
// bc_type="natural") and return a function that evaluates it at any t.
|
||||||
|
function naturalCubicSpline(t: number[], y: number[]): (tq: number) => number {
|
||||||
|
const n = t.length;
|
||||||
|
if (n < 2) return () => y[0] ?? 0;
|
||||||
|
if (n === 2) {
|
||||||
|
return (tq) => y[0] + (y[1] - y[0]) * (tq - t[0]) / (t[1] - t[0]);
|
||||||
|
}
|
||||||
|
const h = new Array(n - 1);
|
||||||
|
for (let i = 0; i < n - 1; i++) h[i] = t[i + 1] - t[i];
|
||||||
|
|
||||||
|
// Tridiagonal system for second derivatives M[1..n-2] (M[0] = M[n-1] = 0).
|
||||||
|
const a = new Array(n).fill(0);
|
||||||
|
const b = new Array(n).fill(0);
|
||||||
|
const c = new Array(n).fill(0);
|
||||||
|
const d = new Array(n).fill(0);
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
a[i] = h[i - 1];
|
||||||
|
b[i] = 2 * (h[i - 1] + h[i]);
|
||||||
|
c[i] = h[i];
|
||||||
|
d[i] = 6 * ((y[i + 1] - y[i]) / h[i] - (y[i] - y[i - 1]) / h[i - 1]);
|
||||||
|
}
|
||||||
|
for (let i = 2; i < n - 1; i++) {
|
||||||
|
const w = a[i] / b[i - 1];
|
||||||
|
b[i] -= w * c[i - 1];
|
||||||
|
d[i] -= w * d[i - 1];
|
||||||
|
}
|
||||||
|
const M = new Array(n).fill(0);
|
||||||
|
if (n >= 3) {
|
||||||
|
M[n - 2] = d[n - 2] / b[n - 2];
|
||||||
|
for (let i = n - 3; i >= 1; i--) {
|
||||||
|
M[i] = (d[i] - c[i] * M[i + 1]) / b[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (tq) => {
|
||||||
|
let i = 0;
|
||||||
|
while (i < n - 2 && tq > t[i + 1]) i++;
|
||||||
|
const dx = h[i];
|
||||||
|
const A = (t[i + 1] - tq) / dx;
|
||||||
|
const B = (tq - t[i]) / dx;
|
||||||
|
return A * y[i] + B * y[i + 1]
|
||||||
|
+ ((A ** 3 - A) * M[i] + (B ** 3 - B) * M[i + 1]) * (dx * dx) / 6;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURVE_SAMPLES_PER_SEGMENT = 24;
|
||||||
|
|
||||||
|
function buildCurvePath(points: Point[]): string {
|
||||||
|
if (points.length === 0) return '';
|
||||||
|
if (points.length === 1) return `M ${points[0].x * 100} ${points[0].y * 100}`;
|
||||||
|
if (points.length === 2) {
|
||||||
|
return `M ${points[0].x * 100} ${points[0].y * 100} L ${points[1].x * 100} ${points[1].y * 100}`;
|
||||||
|
}
|
||||||
|
const n = points.length;
|
||||||
|
const t = Array.from({ length: n }, (_, i) => i / (n - 1));
|
||||||
|
const xs = points.map(p => p.x);
|
||||||
|
const ys = points.map(p => p.y);
|
||||||
|
const fx = naturalCubicSpline(t, xs);
|
||||||
|
const fy = naturalCubicSpline(t, ys);
|
||||||
|
|
||||||
|
const total = (n - 1) * CURVE_SAMPLES_PER_SEGMENT;
|
||||||
|
const segs: string[] = [`M ${points[0].x * 100} ${points[0].y * 100}`];
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
const tq = i / total;
|
||||||
|
segs.push(`L ${fx(tq) * 100} ${fy(tq) * 100}`);
|
||||||
|
}
|
||||||
|
return segs.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointsKey(points: Point[]) {
|
||||||
|
return points.map(p => `${round3(p.x)},${round3(p.y)}`).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StraightenPathOverlay({
|
||||||
|
image, points, thickness, xres, yres,
|
||||||
|
nodeId, onWidgetChange,
|
||||||
|
}: StraightenPathOverlayProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [draft, setDraft] = useState<Point[] | null>(null);
|
||||||
|
const draggingRef = useRef<number | null>(null);
|
||||||
|
const pendingCommitRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingCommitRef.current !== null
|
||||||
|
&& pointsKey(points) === pendingCommitRef.current) {
|
||||||
|
pendingCommitRef.current = null;
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const commit = useCallback((next: Point[]) => {
|
||||||
|
pendingCommitRef.current = pointsKey(next);
|
||||||
|
const { points_x, points_y } = pointsToStrings(next);
|
||||||
|
onWidgetChange(nodeId, 'points_x', points_x);
|
||||||
|
onWidgetChange(nodeId, 'points_y', points_y);
|
||||||
|
}, [nodeId, onWidgetChange]);
|
||||||
|
|
||||||
|
const displayPoints = draft ?? points;
|
||||||
|
|
||||||
|
const onPointerDownMarker = useCallback((idx: number) => (e: React.PointerEvent<Element>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.shiftKey && displayPoints.length > 2) {
|
||||||
|
const next = displayPoints.filter((_, i) => i !== idx);
|
||||||
|
setDraft(next);
|
||||||
|
commit(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
draggingRef.current = idx;
|
||||||
|
setDraft(displayPoints);
|
||||||
|
}, [displayPoints, commit]);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
|
const idx = draggingRef.current;
|
||||||
|
if (idx === null || !containerRef.current) return;
|
||||||
|
const { fx, fy } = pointerToFraction(e, containerRef.current);
|
||||||
|
setDraft(prev => {
|
||||||
|
const base = prev ?? points;
|
||||||
|
return base.map((p, i) => i === idx
|
||||||
|
? { x: clampFraction(fx), y: clampFraction(fy) }
|
||||||
|
: p);
|
||||||
|
});
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
if (draggingRef.current !== null && draft) {
|
||||||
|
commit(draft);
|
||||||
|
}
|
||||||
|
draggingRef.current = null;
|
||||||
|
}, [draft, commit]);
|
||||||
|
|
||||||
|
const onDoubleClick = useCallback((e: React.MouseEvent<Element>) => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const fx = clampFraction((e.clientX - rect.left) / rect.width);
|
||||||
|
const fy = clampFraction((e.clientY - rect.top) / rect.height);
|
||||||
|
const next = [...displayPoints, { x: fx, y: fy }];
|
||||||
|
setDraft(next);
|
||||||
|
commit(next);
|
||||||
|
}, [displayPoints, commit]);
|
||||||
|
|
||||||
|
const curveD = buildCurvePath(displayPoints);
|
||||||
|
|
||||||
|
const refRes = Math.max(xres, yres) || 1;
|
||||||
|
const bandWidthPct = (thickness / refRes) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="nodrag nowheel straighten-overlay"
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
onDoubleClick={onDoubleClick}
|
||||||
|
>
|
||||||
|
<img src={image} alt="field" draggable={false} className="straighten-image" />
|
||||||
|
|
||||||
|
<svg className="straighten-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
{curveD && bandWidthPct > 0 && (
|
||||||
|
<path
|
||||||
|
d={curveD}
|
||||||
|
className="straighten-band"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth={bandWidthPct}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{curveD && (
|
||||||
|
<path d={curveD} className="straighten-curve" fill="none" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{displayPoints.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="straighten-marker"
|
||||||
|
style={{ left: `${p.x * 100}%`, top: `${p.y * 100}%` }}
|
||||||
|
onPointerDown={onPointerDownMarker(i)}
|
||||||
|
title="shift-click to remove"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1689,6 +1689,53 @@ html, body, #root {
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Straighten Path overlay ──────────────────────────────────────── */
|
||||||
|
.straighten-overlay {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
.straighten-image {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.straighten-svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.straighten-band {
|
||||||
|
stroke: var(--accent-lighter);
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
.straighten-curve {
|
||||||
|
stroke: var(--marker);
|
||||||
|
stroke-width: 1.4;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
.straighten-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;
|
||||||
|
}
|
||||||
|
.straighten-marker:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
background: var(--marker-active);
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
.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);
|
||||||
@@ -1944,7 +1991,8 @@ html, body, #root {
|
|||||||
.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 {
|
.is-panning .radial-overlay,
|
||||||
|
.is-panning .straighten-overlay {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ export interface OverlayData {
|
|||||||
square?: boolean;
|
square?: boolean;
|
||||||
a_locked?: boolean;
|
a_locked?: boolean;
|
||||||
b_locked?: boolean;
|
b_locked?: boolean;
|
||||||
|
points?: Array<{ x: number; y: number }>;
|
||||||
|
thickness?: number;
|
||||||
|
xres?: number;
|
||||||
|
yres?: number;
|
||||||
section_title?: string;
|
section_title?: string;
|
||||||
line?: number[];
|
line?: number[];
|
||||||
shape?: string;
|
shape?: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
|
|||||||
'.markup-overlay', // MarkupOverlay
|
'.markup-overlay', // MarkupOverlay
|
||||||
'.angle-overlay', // AngleMeasureOverlay
|
'.angle-overlay', // AngleMeasureOverlay
|
||||||
'.radial-overlay', // RadialProfileOverlay
|
'.radial-overlay', // RadialProfileOverlay
|
||||||
|
'.straighten-overlay', // StraightenPathOverlay
|
||||||
];
|
];
|
||||||
|
|
||||||
function encodeBase64(bytes: Uint8Array) {
|
function encodeBase64(bytes: Uint8Array) {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ def test_basic_extraction():
|
|||||||
|
|
||||||
node = StraightenPath()
|
node = StraightenPath()
|
||||||
field = make_field(shape=(64, 64))
|
field = make_field(shape=(64, 64))
|
||||||
(result,) = node.process(field, points_x="0.25, 0.5, 0.75",
|
result, profile = node.process(field, points_x="0.25, 0.5, 0.75",
|
||||||
points_y="0.5, 0.3, 0.5",
|
points_y="0.5, 0.3, 0.5",
|
||||||
thickness=1, n_samples=256)
|
thickness=1, n_samples=256)
|
||||||
assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}"
|
assert result.data.shape[1] == 256, f"Output width should be n_samples=256, got {result.data.shape[1]}"
|
||||||
|
assert profile.data.shape == (256,)
|
||||||
|
|
||||||
|
|
||||||
def test_thickness():
|
def test_thickness():
|
||||||
@@ -19,10 +20,14 @@ def test_thickness():
|
|||||||
|
|
||||||
node = StraightenPath()
|
node = StraightenPath()
|
||||||
field = make_field(shape=(64, 64))
|
field = make_field(shape=(64, 64))
|
||||||
(result,) = node.process(field, points_x="0.2, 0.8",
|
result, profile = node.process(field, points_x="0.2, 0.8",
|
||||||
points_y="0.5, 0.5",
|
points_y="0.5, 0.5",
|
||||||
thickness=5, n_samples=100)
|
thickness=5, n_samples=100)
|
||||||
assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}"
|
assert result.data.shape[0] == 5, f"Output height should be thickness=5, got {result.data.shape[0]}"
|
||||||
|
# Profile is the 1-pixel-wide centerline regardless of thickness.
|
||||||
|
assert profile.data.shape == (100,)
|
||||||
|
# For a horizontal line, the centerline equals the middle row of the strip.
|
||||||
|
assert np.allclose(profile.data, result.data[2])
|
||||||
|
|
||||||
|
|
||||||
def test_single_point_returns_input():
|
def test_single_point_returns_input():
|
||||||
@@ -30,8 +35,33 @@ def test_single_point_returns_input():
|
|||||||
|
|
||||||
node = StraightenPath()
|
node = StraightenPath()
|
||||||
field = make_field(shape=(64, 64))
|
field = make_field(shape=(64, 64))
|
||||||
(result,) = node.process(field, points_x="0.5",
|
result, profile = node.process(field, points_x="0.5",
|
||||||
points_y="0.5",
|
points_y="0.5",
|
||||||
thickness=1, n_samples=100)
|
thickness=1, n_samples=100)
|
||||||
# With only 1 point, node returns the original field unchanged
|
# With only 1 point, node returns the original field unchanged + empty profile.
|
||||||
assert np.array_equal(result.data, field.data)
|
assert np.array_equal(result.data, field.data)
|
||||||
|
assert profile.data.shape == (0,)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_overlay_with_points_and_thickness():
|
||||||
|
from backend.execution_context import active_node, execution_callbacks
|
||||||
|
from backend.nodes.straighten_path import StraightenPath
|
||||||
|
|
||||||
|
node = StraightenPath()
|
||||||
|
field = make_field(shape=(64, 64))
|
||||||
|
|
||||||
|
overlays = []
|
||||||
|
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
|
||||||
|
node.process(field, points_x="0.25, 0.5, 0.75",
|
||||||
|
points_y="0.5, 0.3, 0.5",
|
||||||
|
thickness=4, n_samples=128)
|
||||||
|
|
||||||
|
assert len(overlays) == 1
|
||||||
|
ov = overlays[0]
|
||||||
|
assert ov["kind"] == "straighten_path"
|
||||||
|
assert ov["section_title"] == "Path"
|
||||||
|
assert ov["image"].startswith("data:image/png;base64,")
|
||||||
|
assert ov["thickness"] == 4
|
||||||
|
assert ov["xres"] == 64 and ov["yres"] == 64
|
||||||
|
assert [p["x"] for p in ov["points"]] == [0.25, 0.5, 0.75]
|
||||||
|
assert [p["y"] for p in ov["points"]] == [0.5, 0.3, 0.5]
|
||||||
|
|||||||
Reference in New Issue
Block a user