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