fix perspective correction
This commit is contained in:
@@ -6,25 +6,34 @@ import numpy as np
|
|||||||
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, datafield_to_uint8, encode_preview
|
||||||
|
from backend.execution_context import emit_overlay
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="Perspective Correction")
|
@register_node(display_name="Perspective Correction")
|
||||||
class PerspectiveCorrection:
|
class PerspectiveCorrection:
|
||||||
|
_CUSTOM_PREVIEW = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"field": ("DATA_FIELD",),
|
"field": ("DATA_FIELD",),
|
||||||
"top_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"top_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"top_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"top_left_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"top_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"top_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"top_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"top_right_y": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"bottom_left_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"bottom_left_x": ("FLOAT", {"default": 0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"bottom_left_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"bottom_left_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"bottom_right_x": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"bottom_right_x": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
"bottom_right_y": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
|
"bottom_right_y": ("FLOAT", {"default": -0.1, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||||
}
|
},
|
||||||
|
"optional": {
|
||||||
|
"top_left": ("COORD",),
|
||||||
|
"top_right": ("COORD",),
|
||||||
|
"bottom_left": ("COORD",),
|
||||||
|
"bottom_right": ("COORD",),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
OUTPUTS = (
|
OUTPUTS = (
|
||||||
@@ -33,9 +42,8 @@ class PerspectiveCorrection:
|
|||||||
FUNCTION = "process"
|
FUNCTION = "process"
|
||||||
|
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Fix perspective distortion by specifying corner offsets. Each corner "
|
"Fix perspective distortion by dragging corner handles. Each corner "
|
||||||
"can be shifted by a fractional amount (relative to image size) to "
|
"offset defines a distorted quadrilateral that is warped back to "
|
||||||
"define the distorted quadrilateral. The image is then warped back to "
|
|
||||||
"a rectangle."
|
"a rectangle."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,11 +53,23 @@ class PerspectiveCorrection:
|
|||||||
top_left_x: float, top_left_y: float,
|
top_left_x: float, top_left_y: float,
|
||||||
top_right_x: float, top_right_y: float,
|
top_right_x: float, top_right_y: float,
|
||||||
bottom_left_x: float, bottom_left_y: float,
|
bottom_left_x: float, bottom_left_y: float,
|
||||||
bottom_right_x: float, bottom_right_y: float) -> tuple:
|
bottom_right_x: float, bottom_right_y: float,
|
||||||
|
top_left: tuple[float, float] | None = None,
|
||||||
|
top_right: tuple[float, float] | None = None,
|
||||||
|
bottom_left: tuple[float, float] | None = None,
|
||||||
|
bottom_right: tuple[float, float] | None = None) -> tuple:
|
||||||
|
if top_left is not None:
|
||||||
|
top_left_x, top_left_y = float(top_left[0]), float(top_left[1])
|
||||||
|
if top_right is not None:
|
||||||
|
top_right_x, top_right_y = float(top_right[0]), float(top_right[1])
|
||||||
|
if bottom_left is not None:
|
||||||
|
bottom_left_x, bottom_left_y = float(bottom_left[0]), float(bottom_left[1])
|
||||||
|
if bottom_right is not None:
|
||||||
|
bottom_right_x, bottom_right_y = float(bottom_right[0]), float(bottom_right[1])
|
||||||
|
|
||||||
data = np.asarray(field.data, dtype=np.float64)
|
data = np.asarray(field.data, dtype=np.float64)
|
||||||
yres, xres = data.shape
|
yres, xres = data.shape
|
||||||
|
|
||||||
# Source corners (distorted) as fractional offsets from ideal corners
|
|
||||||
src = np.array([
|
src = np.array([
|
||||||
[top_left_y * yres, top_left_x * xres],
|
[top_left_y * yres, top_left_x * xres],
|
||||||
[top_right_y * yres, top_right_x * xres + (xres - 1)],
|
[top_right_y * yres, top_right_x * xres + (xres - 1)],
|
||||||
@@ -57,7 +77,6 @@ class PerspectiveCorrection:
|
|||||||
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
|
[(1 + bottom_right_y) * yres - 1, bottom_right_x * xres + (xres - 1)],
|
||||||
], dtype=np.float64)
|
], dtype=np.float64)
|
||||||
|
|
||||||
# Destination corners (ideal rectangle)
|
|
||||||
dst = np.array([
|
dst = np.array([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[0, xres - 1],
|
[0, xres - 1],
|
||||||
@@ -65,33 +84,54 @@ class PerspectiveCorrection:
|
|||||||
[yres - 1, xres - 1],
|
[yres - 1, xres - 1],
|
||||||
], dtype=np.float64)
|
], dtype=np.float64)
|
||||||
|
|
||||||
# Solve for perspective transform matrix (3x3)
|
|
||||||
H = _solve_perspective(src, dst)
|
H = _solve_perspective(src, dst)
|
||||||
|
|
||||||
# Apply inverse warp
|
|
||||||
yy, xx = np.mgrid[:yres, :xres]
|
yy, xx = np.mgrid[:yres, :xres]
|
||||||
coords = np.stack([yy.ravel(), xx.ravel(), np.ones(yres * xres)])
|
coords = np.stack([xx.ravel(), yy.ravel(), np.ones(yres * xres)])
|
||||||
src_coords = H @ coords
|
src_coords = H @ coords
|
||||||
src_coords /= src_coords[2:3, :]
|
src_coords /= src_coords[2:3, :]
|
||||||
sy = src_coords[0].reshape(yres, xres)
|
sx = src_coords[0].reshape(yres, xres)
|
||||||
sx = src_coords[1].reshape(yres, xres)
|
sy = src_coords[1].reshape(yres, xres)
|
||||||
|
|
||||||
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
result = map_coordinates(data, [sy, sx], order=1, mode='nearest')
|
||||||
return (field.replace(data=result),)
|
corrected = field.replace(data=result)
|
||||||
|
|
||||||
|
source_rgb = datafield_to_uint8(field, field.colormap)
|
||||||
|
corrected_rgb = datafield_to_uint8(corrected, corrected.colormap)
|
||||||
|
|
||||||
|
corners = [
|
||||||
|
{"x": float(top_left_x), "y": float(top_left_y)},
|
||||||
|
{"x": float(top_right_x), "y": float(top_right_y)},
|
||||||
|
{"x": float(bottom_left_x), "y": float(bottom_left_y)},
|
||||||
|
{"x": float(bottom_right_x), "y": float(bottom_right_y)},
|
||||||
|
]
|
||||||
|
|
||||||
|
emit_overlay({
|
||||||
|
"kind": "perspective",
|
||||||
|
"section_title": "Perspective",
|
||||||
|
"image": encode_preview(source_rgb),
|
||||||
|
"corrected_image": encode_preview(corrected_rgb),
|
||||||
|
"corners": corners,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (corrected,)
|
||||||
|
|
||||||
|
|
||||||
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
|
def _solve_perspective(src: np.ndarray, dst: np.ndarray) -> np.ndarray:
|
||||||
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp)."""
|
"""Solve for 3x3 perspective matrix mapping dst -> src (for inverse warp).
|
||||||
|
|
||||||
|
Coordinates are (col, row) — the matrix is applied to [col, row, 1] vectors.
|
||||||
|
"""
|
||||||
n = len(src)
|
n = len(src)
|
||||||
A = np.zeros((2 * n, 8))
|
A = np.zeros((2 * n, 8))
|
||||||
b = np.zeros(2 * n)
|
b = np.zeros(2 * n)
|
||||||
for i in range(n):
|
for i in range(n):
|
||||||
dy, dx = dst[i]
|
dr, dc = dst[i] # dest row, col
|
||||||
sy, sx = src[i]
|
sr, sc = src[i] # src row, col
|
||||||
A[2 * i] = [dx, dy, 1, 0, 0, 0, -sx * dx, -sx * dy]
|
A[2 * i] = [dc, dr, 1, 0, 0, 0, -sc * dc, -sc * dr]
|
||||||
A[2 * i + 1] = [0, 0, 0, dx, dy, 1, -sy * dx, -sy * dy]
|
A[2 * i + 1] = [0, 0, 0, dc, dr, 1, -sr * dc, -sr * dr]
|
||||||
b[2 * i] = sx
|
b[2 * i] = sc
|
||||||
b[2 * i + 1] = sy
|
b[2 * i + 1] = sr
|
||||||
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
h, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
||||||
H = np.array([[h[0], h[1], h[2]],
|
H = np.array([[h[0], h[1], h[2]],
|
||||||
[h[3], h[4], h[5]],
|
[h[3], h[4], h[5]],
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
# Perspective Correction
|
# Perspective Correction
|
||||||
|
|
||||||
Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle. Equivalent to Gwyddion's `correct_perspective.c` module.
|
Fix perspective distortion in a DATA_FIELD via a projective (homography) transform. Each corner can be shifted by a fractional offset to map a distorted quadrilateral back to a rectangle.
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
| Name | Type | Required | Description |
|
| Name | Type | Required | Description |
|
||||||
|------|------|----------|-------------|
|
|------|------|----------|-------------|
|
||||||
| field | DATA_FIELD | Yes | Input field with perspective distortion |
|
| field | DATA_FIELD | Yes | Input field with perspective distortion |
|
||||||
|
| top_left | COORD | No | Override top-left corner offset (x, y) |
|
||||||
|
| top_right | COORD | No | Override top-right corner offset (x, y) |
|
||||||
|
| bottom_left | COORD | No | Override bottom-left corner offset (x, y) |
|
||||||
|
| bottom_right | COORD | No | Override bottom-right corner offset (x, y) |
|
||||||
|
|
||||||
## Outputs
|
## Outputs
|
||||||
|
|
||||||
@@ -14,22 +18,15 @@ Fix perspective distortion in a DATA_FIELD via a projective (homography) transfo
|
|||||||
|------|------|-------------|
|
|------|------|-------------|
|
||||||
| corrected | DATA_FIELD | Perspective-corrected field |
|
| corrected | DATA_FIELD | Perspective-corrected field |
|
||||||
|
|
||||||
## Controls
|
## Interactive preview
|
||||||
|
|
||||||
| Name | Type | Default | Description |
|
The preview shows the source image with a draggable quadrilateral overlay. Drag any corner handle to adjust the perspective correction. Use the Source/Corrected tabs to switch between the input image (with handles) and the corrected result.
|
||||||
|------|------|---------|-------------|
|
|
||||||
| top_left_x | FLOAT | 0.0 | Horizontal offset of the top-left corner as a fraction of image width (-0.5-0.5) |
|
Corner positions can also be set by connecting Coordinate nodes to the optional COORD inputs, which override the handle-driven values.
|
||||||
| top_left_y | FLOAT | 0.0 | Vertical offset of the top-left corner as a fraction of image height (-0.5-0.5) |
|
|
||||||
| top_right_x | FLOAT | 0.0 | Horizontal offset of the top-right corner as a fraction of image width (-0.5-0.5) |
|
|
||||||
| top_right_y | FLOAT | 0.0 | Vertical offset of the top-right corner as a fraction of image height (-0.5-0.5) |
|
|
||||||
| bottom_left_x | FLOAT | 0.0 | Horizontal offset of the bottom-left corner as a fraction of image width (-0.5-0.5) |
|
|
||||||
| bottom_left_y | FLOAT | 0.0 | Vertical offset of the bottom-left corner as a fraction of image height (-0.5-0.5) |
|
|
||||||
| bottom_right_x | FLOAT | 0.0 | Horizontal offset of the bottom-right corner as a fraction of image width (-0.5-0.5) |
|
|
||||||
| bottom_right_y | FLOAT | 0.0 | Vertical offset of the bottom-right corner as a fraction of image height (-0.5-0.5) |
|
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down.
|
- All offsets are given as fractions of the image dimensions (0.0 = no shift, 0.1 = 10% shift). Positive x shifts right, positive y shifts down.
|
||||||
- The transform uses bilinear interpolation to resample pixel values at non-integer locations.
|
- The transform uses bilinear interpolation to resample pixel values at non-integer locations.
|
||||||
- For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment.
|
- For trapezoidal distortions (common in tilted AFM scans), typically only two corners need adjustment.
|
||||||
- Set all offsets to 0.0 to pass the field through unchanged.
|
- When all offsets are zero (default), the field passes through unchanged.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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'));
|
const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay'));
|
||||||
|
const PerspectiveOverlay = lazy(() => import('./PerspectiveOverlay'));
|
||||||
|
|
||||||
import TextNoteNode from './TextNoteNode';
|
import TextNoteNode from './TextNoteNode';
|
||||||
|
|
||||||
@@ -1202,6 +1203,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
|| 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'
|
|| data.overlay.kind === 'multi_profile'
|
||||||
|
|| data.overlay.kind === 'perspective'
|
||||||
);
|
);
|
||||||
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
|
||||||
@@ -1223,6 +1225,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
? 'Path'
|
? 'Path'
|
||||||
: data.overlay?.kind === 'multi_profile'
|
: data.overlay?.kind === 'multi_profile'
|
||||||
? 'Preview'
|
? 'Preview'
|
||||||
|
: data.overlay?.kind === 'perspective'
|
||||||
|
? 'Perspective'
|
||||||
: 'Cross Section');
|
: 'Cross Section');
|
||||||
const headerMeta = (() => {
|
const headerMeta = (() => {
|
||||||
if (data.className === 'Folder') {
|
if (data.className === 'Folder') {
|
||||||
@@ -1597,6 +1601,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|
|||||||
nodeId={id}
|
nodeId={id}
|
||||||
onWidgetChange={ctx!.onWidgetChange}
|
onWidgetChange={ctx!.onWidgetChange}
|
||||||
/>
|
/>
|
||||||
|
) : data.overlay!.kind === 'perspective' ? (
|
||||||
|
<PerspectiveOverlay
|
||||||
|
image={data.overlay!.image ?? ''}
|
||||||
|
correctedImage={data.overlay!.corrected_image ?? ''}
|
||||||
|
corners={(data.overlay!.corners ?? []) as Array<{ x: number; y: 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 ?? ''}
|
||||||
|
|||||||
150
frontend/src/PerspectiveOverlay.tsx
Normal file
150
frontend/src/PerspectiveOverlay.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||||
|
import { pointerToFraction } from './overlayUtils';
|
||||||
|
|
||||||
|
export const CAPTURE_SELECTOR = '.perspective-overlay';
|
||||||
|
|
||||||
|
const CORNER_NAMES = ['top_left', 'top_right', 'bottom_left', 'bottom_right'] as const;
|
||||||
|
type CornerName = typeof CORNER_NAMES[number];
|
||||||
|
|
||||||
|
const CORNER_ANCHORS: Record<CornerName, { ax: number; ay: number }> = {
|
||||||
|
top_left: { ax: 0, ay: 0 },
|
||||||
|
top_right: { ax: 1, ay: 0 },
|
||||||
|
bottom_left: { ax: 0, ay: 1 },
|
||||||
|
bottom_right: { ax: 1, ay: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Corner { x: number; y: number }
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
image: string;
|
||||||
|
correctedImage: string;
|
||||||
|
corners: Corner[];
|
||||||
|
nodeId: string;
|
||||||
|
onWidgetChange: (nodeId: string, name: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornerToPercent(corner: Corner, name: CornerName) {
|
||||||
|
const anchor = CORNER_ANCHORS[name];
|
||||||
|
return {
|
||||||
|
left: (anchor.ax + corner.x) * 100,
|
||||||
|
top: (anchor.ay + corner.y) * 100,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cornersKey(c: Corner[]): string {
|
||||||
|
return c.map((p) => `${p.x},${p.y}`).join(';');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PerspectiveOverlay({
|
||||||
|
image, correctedImage, corners, nodeId, onWidgetChange,
|
||||||
|
}: Props) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const draggingRef = useRef<CornerName | null>(null);
|
||||||
|
const [draft, setDraft] = useState<Corner[] | null>(null);
|
||||||
|
const pendingCommitRef = useRef<string | null>(null);
|
||||||
|
const [showCorrected, setShowCorrected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingCommitRef.current && cornersKey(corners) === pendingCommitRef.current) {
|
||||||
|
pendingCommitRef.current = null;
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
}, [corners]);
|
||||||
|
|
||||||
|
const liveCorners = draft ?? corners;
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((corner: CornerName) => (e: React.PointerEvent<Element>) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
draggingRef.current = corner;
|
||||||
|
setDraft([...liveCorners]);
|
||||||
|
}, [liveCorners]);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent<Element>) => {
|
||||||
|
const name = draggingRef.current;
|
||||||
|
if (!name || !containerRef.current) return;
|
||||||
|
const { fx, fy } = pointerToFraction(e, containerRef.current);
|
||||||
|
const anchor = CORNER_ANCHORS[name];
|
||||||
|
const cx = Math.max(-1, Math.min(1, parseFloat((fx - anchor.ax).toFixed(3))));
|
||||||
|
const cy = Math.max(-1, Math.min(1, parseFloat((fy - anchor.ay).toFixed(3))));
|
||||||
|
const idx = CORNER_NAMES.indexOf(name);
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { x: cx, y: cy };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
const name = draggingRef.current;
|
||||||
|
if (!name || !draft) {
|
||||||
|
draggingRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
draggingRef.current = null;
|
||||||
|
pendingCommitRef.current = cornersKey(draft);
|
||||||
|
for (let i = 0; i < CORNER_NAMES.length; i++) {
|
||||||
|
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_x`, draft[i].x);
|
||||||
|
onWidgetChange(nodeId, `${CORNER_NAMES[i]}_y`, draft[i].y);
|
||||||
|
}
|
||||||
|
}, [draft, nodeId, onWidgetChange]);
|
||||||
|
|
||||||
|
const positions = CORNER_NAMES.map((name, i) => cornerToPercent(liveCorners[i] || { x: 0, y: 0 }, name));
|
||||||
|
const quadPoints = `${positions[0].left},${positions[0].top} ${positions[1].left},${positions[1].top} ${positions[3].left},${positions[3].top} ${positions[2].left},${positions[2].top}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="perspective-overlay-wrap">
|
||||||
|
<div className="perspective-tab-bar">
|
||||||
|
<button
|
||||||
|
className={`perspective-tab nodrag${!showCorrected ? ' active' : ''}`}
|
||||||
|
onClick={() => setShowCorrected(false)}
|
||||||
|
>
|
||||||
|
Source
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`perspective-tab nodrag${showCorrected ? ' active' : ''}`}
|
||||||
|
onClick={() => setShowCorrected(true)}
|
||||||
|
>
|
||||||
|
Corrected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCorrected ? (
|
||||||
|
<div className="perspective-overlay perspective-corrected">
|
||||||
|
<img src={correctedImage} alt="corrected" draggable={false} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="nodrag nowheel perspective-overlay"
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onLostPointerCapture={onPointerUp}
|
||||||
|
>
|
||||||
|
<img src={image} alt="source" draggable={false} />
|
||||||
|
|
||||||
|
<svg className="perspective-quad" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||||
|
<polygon
|
||||||
|
points={quadPoints}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--selection, #3b82f6)"
|
||||||
|
strokeWidth="0.4"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{CORNER_NAMES.map((name, i) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className={`perspective-handle${draggingRef.current === name ? ' dragging' : ''}`}
|
||||||
|
style={{ left: `${positions[i].left}%`, top: `${positions[i].top}%` }}
|
||||||
|
onPointerDown={onPointerDown(name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1801,6 +1801,73 @@ html, body, #root {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Perspective correction overlay ──────────────────────────────────── */
|
||||||
|
.perspective-overlay-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.perspective-tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--border-default);
|
||||||
|
border-bottom: 1px solid var(--border-default);
|
||||||
|
}
|
||||||
|
.perspective-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s, color 0.1s;
|
||||||
|
}
|
||||||
|
.perspective-tab:hover {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.perspective-tab.active {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.perspective-overlay {
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.perspective-overlay img {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.perspective-quad {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.perspective-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--selection, #3b82f6);
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.5);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
cursor: grab;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.perspective-handle:hover,
|
||||||
|
.perspective-handle.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: translate(-50%, -50%) scale(1.2);
|
||||||
|
}
|
||||||
|
.is-panning .perspective-overlay {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export interface OverlayData {
|
|||||||
row?: number;
|
row?: number;
|
||||||
direction?: 'horizontal' | 'vertical';
|
direction?: 'horizontal' | 'vertical';
|
||||||
max_index?: number;
|
max_index?: number;
|
||||||
|
corrected_image?: string;
|
||||||
|
corners?: Array<{ x: number; y: number }>;
|
||||||
section_title?: string;
|
section_title?: string;
|
||||||
line?: number[];
|
line?: number[];
|
||||||
shape?: string;
|
shape?: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
|
|||||||
'.radial-overlay', // RadialProfileOverlay
|
'.radial-overlay', // RadialProfileOverlay
|
||||||
'.straighten-overlay', // StraightenPathOverlay
|
'.straighten-overlay', // StraightenPathOverlay
|
||||||
'.multiprofile-overlay', // MultiProfileOverlay
|
'.multiprofile-overlay', // MultiProfileOverlay
|
||||||
|
'.perspective-overlay', // PerspectiveOverlay
|
||||||
];
|
];
|
||||||
|
|
||||||
function encodeBase64(bytes: Uint8Array) {
|
function encodeBase64(bytes: Uint8Array) {
|
||||||
|
|||||||
@@ -51,3 +51,57 @@ def test_output_shape():
|
|||||||
bottom_right_x=0.0, bottom_right_y=0.0,
|
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||||
)
|
)
|
||||||
assert result.data.shape == (48, 96)
|
assert result.data.shape == (48, 96)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emits_perspective_overlay():
|
||||||
|
from backend.execution_context import active_node, execution_callbacks
|
||||||
|
from backend.nodes.perspective_correction import PerspectiveCorrection
|
||||||
|
|
||||||
|
node = PerspectiveCorrection()
|
||||||
|
field = make_field(shape=(64, 64))
|
||||||
|
|
||||||
|
overlays = []
|
||||||
|
with execution_callbacks(overlay=lambda nid, d: overlays.append(d)), active_node("test"):
|
||||||
|
node.process(
|
||||||
|
field,
|
||||||
|
top_left_x=0.05, top_left_y=0.05,
|
||||||
|
top_right_x=-0.05, top_right_y=0.05,
|
||||||
|
bottom_left_x=0.05, bottom_left_y=-0.05,
|
||||||
|
bottom_right_x=-0.05, bottom_right_y=-0.05,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(overlays) == 1
|
||||||
|
ov = overlays[0]
|
||||||
|
assert ov["kind"] == "perspective"
|
||||||
|
assert ov["section_title"] == "Perspective"
|
||||||
|
assert ov["image"].startswith("data:image/png;base64,")
|
||||||
|
assert ov["corrected_image"].startswith("data:image/png;base64,")
|
||||||
|
assert len(ov["corners"]) == 4
|
||||||
|
assert ov["corners"][0] == {"x": 0.05, "y": 0.05}
|
||||||
|
assert ov["corners"][3] == {"x": -0.05, "y": -0.05}
|
||||||
|
|
||||||
|
|
||||||
|
def test_coord_input_overrides_floats():
|
||||||
|
from backend.nodes.perspective_correction import PerspectiveCorrection
|
||||||
|
|
||||||
|
node = PerspectiveCorrection()
|
||||||
|
field = make_field(shape=(64, 64))
|
||||||
|
|
||||||
|
result_floats, = node.process(
|
||||||
|
field,
|
||||||
|
top_left_x=0.1, top_left_y=0.1,
|
||||||
|
top_right_x=0.0, top_right_y=0.0,
|
||||||
|
bottom_left_x=0.0, bottom_left_y=0.0,
|
||||||
|
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_coord, = node.process(
|
||||||
|
field,
|
||||||
|
top_left_x=0.0, top_left_y=0.0,
|
||||||
|
top_right_x=0.0, top_right_y=0.0,
|
||||||
|
bottom_left_x=0.0, bottom_left_y=0.0,
|
||||||
|
bottom_right_x=0.0, bottom_right_y=0.0,
|
||||||
|
top_left=(0.1, 0.1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert np.allclose(result_floats.data, result_coord.data)
|
||||||
|
|||||||
Reference in New Issue
Block a user