import { useEffect, useState } from "react"; export interface Point { x: number; y: number; } /** TL, TR, BR, BL */ export type Quad = [Point, Point, Point, Point]; export function fullImageQuad(width: number, height: number): Quad { return [ { x: 0, y: 0 }, { x: width, y: 0 }, { x: width, y: height }, { x: 0, y: height }, ]; } function findLargestQuad(cv: any, src: any, minAreaFraction = 0.1): Quad | null { let gray = new cv.Mat(); let blurred = new cv.Mat(); let edges = new cv.Mat(); let dilated = new cv.Mat(); let contours = new cv.MatVector(); let hierarchy = new cv.Mat(); try { cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY); cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0); // Lower thresholds catch document-background boundaries even in low-contrast images cv.Canny(blurred, edges, 40, 120); // Dilate to close small gaps along document edges let kernel = cv.getStructuringElement(cv.MORPH_RECT, new cv.Size(3, 3)); cv.dilate(edges, dilated, kernel); kernel.delete(); cv.findContours(dilated, contours, hierarchy, cv.RETR_LIST, cv.CHAIN_APPROX_SIMPLE); let best: unknown = null; let bestArea = 0; let minArea = src.cols * src.rows * minAreaFraction; for (let i = 0; i < contours.size(); i++) { let contour = contours.get(i); let peri = cv.arcLength(contour, true); let approx = new cv.Mat(); // Larger epsilon is more aggressive — helps when the outline isn't perfectly straight cv.approxPolyDP(contour, approx, 0.04 * peri, true); if (approx.rows === 4) { let area = cv.contourArea(approx); if (area > bestArea && area > minArea) { bestArea = area; (best as any)?.delete(); best = approx; } else { approx.delete(); } } else { approx.delete(); } contour.delete(); } let quad: Quad | null = null; if (best) { let mat = best as any; let pts: Point[] = []; for (let i = 0; i < 4; i++) { pts.push({ x: mat.data32S[i * 2], y: mat.data32S[i * 2 + 1] }); } mat.delete(); // Sort into TL, TR, BR, BL pts.sort((a, b) => a.x + a.y - (b.x + b.y)); let tl = pts[0]; let br = pts[3]; let remaining = [pts[1], pts[2]]; let tr = remaining[0].x > remaining[1].x ? remaining[0] : remaining[1]; let bl = remaining[0].x < remaining[1].x ? remaining[0] : remaining[1]; quad = [tl, tr, br, bl]; } return quad; } finally { gray.delete(); blurred.delete(); edges.delete(); dilated.delete(); contours.delete(); hierarchy.delete(); } } interface UseEdgeDetectionResult { corners: Quad | null; } /** * Runs Canny edge detection on the image to suggest document corners. * Falls back to full-image corners if no quadrilateral is found. */ export function useEdgeDetection(cv: any, canvas: HTMLCanvasElement | null, enabled: boolean): UseEdgeDetectionResult { let [corners, setCorners] = useState(null); useEffect(() => { if (!enabled || !cv || !canvas) return; try { let src = cv.imread(canvas); let quad: Quad | null; try { quad = findLargestQuad(cv, src); } finally { src.delete(); } setCorners(quad ?? fullImageQuad(canvas.width, canvas.height)); } catch (err) { console.warn("[ImageEditor] Edge detection failed, falling back to the full image:", err); setCorners(fullImageQuad(canvas.width, canvas.height)); } }, [cv, canvas, enabled]); return { corners }; }