fix perspective correction

This commit is contained in:
2026-04-16 22:41:56 -07:00
parent a4c8d2b01c
commit d35cdd6971
8 changed files with 365 additions and 42 deletions

View File

@@ -15,6 +15,7 @@ const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
const RadialProfileOverlay = lazy(() => import('./RadialProfileOverlay'));
const StraightenPathOverlay = lazy(() => import('./StraightenPathOverlay'));
const MultiProfileOverlay = lazy(() => import('./MultiProfileOverlay'));
const PerspectiveOverlay = lazy(() => import('./PerspectiveOverlay'));
import TextNoteNode from './TextNoteNode';
@@ -1202,6 +1203,7 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
|| data.overlay.kind === 'radial_profile'
|| data.overlay.kind === 'straighten_path'
|| data.overlay.kind === 'multi_profile'
|| data.overlay.kind === 'perspective'
);
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
const overlayTitle = data.overlay?.section_title
@@ -1223,6 +1225,8 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
? 'Path'
: data.overlay?.kind === 'multi_profile'
? 'Preview'
: data.overlay?.kind === 'perspective'
? 'Perspective'
: 'Cross Section');
const headerMeta = (() => {
if (data.className === 'Folder') {
@@ -1597,6 +1601,14 @@ function CustomNode({ id, data }: { id: string; data: NodeData }) {
nodeId={id}
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' ? (
<AngleMeasureOverlay
image={data.overlay!.image ?? ''}

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

View File

@@ -1801,6 +1801,73 @@ html, body, #root {
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-line-color: #ff9800;
--angle-arc-color: rgb(255, 166, 77);

View File

@@ -82,6 +82,8 @@ export interface OverlayData {
row?: number;
direction?: 'horizontal' | 'vertical';
max_index?: number;
corrected_image?: string;
corners?: Array<{ x: number; y: number }>;
section_title?: string;
line?: number[];
shape?: string;

View File

@@ -14,6 +14,7 @@ export const OVERLAY_CAPTURE_SELECTORS = [
'.radial-overlay', // RadialProfileOverlay
'.straighten-overlay', // StraightenPathOverlay
'.multiprofile-overlay', // MultiProfileOverlay
'.perspective-overlay', // PerspectiveOverlay
];
function encodeBase64(bytes: Uint8Array) {