如何修复Canvas绘图时快速绘制导致的顶点检测错误?
问题描述
我开发了一个React Canvas绘图应用,需要识别绘制图形的峰值(最高点)和谷值(最低点)。慢速绘制时检测结果正确,但快速或突然绘制时,顶点识别出现错误。
慢速绘制示例:
快速绘制错误示例:
问题根源
快速绘制时鼠标移动事件触发间隔变大,采集到的点密度降低,原实现存在以下问题:
- 固定大小的移动平均滤波窗口在点稀疏时过度平滑,丢失真实顶点特征
- 依赖当前窗口极值的阈值判断逻辑,在点间距大时容易误判局部波动为顶点
- 未考虑绘制速度(点间距)对检测逻辑的影响,直接按索引划分窗口不合理
解决方案
通过以下三点优化修复问题:
- 路径抽稀:去除冗余点,保留路径关键特征,减少计算量
- 自适应滤波:根据绘制速度动态调整滤波窗口大小,避免过度平滑
- 斜率变化检测:通过相邻点斜率变化识别顶点,替代原阈值逻辑,提升稳定性
修改后的完整代码
import { createRef, MouseEvent, useEffect, useRef, useState } from "react"; type Point = { x: number; y: number; }; const CANVAS_SIZE: [width: number, height: number] = [980, 600]; const LINE_STROKE = { width: 5, color: "#FFFFFF" } as const; // 计算两点距离 function getDistance(p1: Point, p2: Point): number { return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); } // 路径抽稀:保留关键特征点,去除冗余点 function simplifyPath(path: Point[], tolerance: number = 5): Point[] { if (path.length <= 2) return path; const simplified: Point[] = [path[0]]; let lastIndex = 0; for (let i = 1; i < path.length - 1; i++) { const d = getDistance(path[lastIndex], path[i]) + getDistance(path[i], path[i+1]) - getDistance(path[lastIndex], path[i+1]); if (d > tolerance) { simplified.push(path[i]); lastIndex = i; } } simplified.push(path[path.length - 1]); return simplified; } // 自适应移动平均滤波:根据绘制速度调整窗口大小 function adaptiveSmooth(path: Point[]): number[] { const smoothY: number[] = []; const baseWindow = 5; // 基础窗口大小 for (let i = 0; i < path.length; i++) { // 计算当前点与前后点的平均距离,判断绘制速度 const prevDist = i > 0 ? getDistance(path[i], path[i-1]) : 0; const nextDist = i < path.length - 1 ? getDistance(path[i], path[i+1]) : 0; const avgDist = (prevDist + nextDist) / 2; // 速度越快(距离越大),窗口越小,避免过度平滑 const windowSize = Math.max(2, Math.floor(baseWindow / (avgDist / 10 + 1))); const start = Math.max(0, i - windowSize); const end = Math.min(path.length - 1, i + windowSize); let sum = 0; for (let j = start; j <= end; j++) { sum += path[j].y; } smoothY.push(sum / (end - start + 1)); } return smoothY; } // 基于斜率变化检测峰值和谷值 function findPeaksAndValleys(path: Point[]) { const simplifiedPath = simplifyPath(path); if (simplifiedPath.length < 3) return { peaks: [], valleys: [] }; const smoothY = adaptiveSmooth(simplifiedPath); const peaks: Point[] = []; const valleys: Point[] = []; // 遍历平滑后的路径,通过斜率变化识别顶点 for (let i = 1; i < smoothY.length - 1; i++) { const prevSlope = smoothY[i] - smoothY[i-1]; const nextSlope = smoothY[i+1] - smoothY[i]; // 峰值:前斜率为正,后斜率为负 if (prevSlope > 0 && nextSlope < 0) { // 在局部范围内查找原始路径的真实最高点 const startIdx = Math.max(0, i - 2); const endIdx = Math.min(simplifiedPath.length - 1, i + 2); let peakIdx = i; let maxY = simplifiedPath[i].y; for (let j = startIdx; j <= endIdx; j++) { if (simplifiedPath[j].y > maxY) { maxY = simplifiedPath[j].y; peakIdx = j; } } peaks.push(simplifiedPath[peakIdx]); } // 谷值:前斜率为负,后斜率为正 if (prevSlope < 0 && nextSlope > 0) { // 在局部范围内查找原始路径的真实最低点 const startIdx = Math.max(0, i - 2); const endIdx = Math.min(simplifiedPath.length - 1, i + 2); let valleyIdx = i; let minY = simplifiedPath[i].y; for (let j = startIdx; j <= endIdx; j++) { if (simplifiedPath[j].y < minY) { minY = simplifiedPath[j].y; valleyIdx = j; } } valleys.push(simplifiedPath[valleyIdx]); } } return { peaks, valleys }; } export default function App() { const loadRef = useRef<boolean>(false); const canvasRef = createRef<HTMLCanvasElement>(); const contextRef = useRef<CanvasRenderingContext2D>(); const [isDrawing, setIsDrawing] = useState<boolean>(false); const [path, setPath] = useState<Point[]>([]); useEffect(() => { function prepareCanvas() { const canvas = canvasRef.current!; canvas.width = CANVAS_SIZE[0]; canvas.height = CANVAS_SIZE[1]; canvas.style.width = CANVAS_SIZE[0] + "px"; canvas.style.height = CANVAS_SIZE[1] + "px"; const ctx = canvas.getContext("2d")!; ctx.lineCap = "round"; ctx.shadowColor = "rgba(126, 196, 255, 0.102)"; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 5; ctx.shadowBlur = 10; contextRef.current = ctx; } if (!loadRef.current) { loadRef.current = true; prepareCanvas(); } }, []); function clearCanvas() { contextRef.current!.clearRect(0, 0, canvasRef.current!.width, canvasRef.current!.height); } function startDrawing({ nativeEvent }: MouseEvent<HTMLCanvasElement>) { if (!contextRef.current) return; clearCanvas(); const { offsetX: x, offsetY: y } = nativeEvent; const ctx = contextRef.current; ctx.strokeStyle = LINE_STROKE.color; ctx.lineWidth = LINE_STROKE.width; ctx.beginPath(); ctx.moveTo(x, y); setIsDrawing(true); setPath([{ x, y }]); } function finishDrawing() { if (!contextRef.current) return; setIsDrawing(false); const ctx = contextRef.current; const { peaks, valleys } = findPeaksAndValleys(path); ctx.closePath(); const circleRadius = 10; // 绘制峰值 ctx.fillStyle = "red"; peaks.forEach((peak) => { ctx.beginPath(); ctx.arc(peak.x, peak.y, circleRadius, 0, 2 * Math.PI); ctx.fill(); }); // 绘制谷值 ctx.fillStyle = "blue"; valleys.forEach((valley) => { ctx.beginPath(); ctx.arc(valley.x, valley.y, circleRadius, 0, 2 * Math.PI); ctx.fill(); }); } function onDraw({ nativeEvent }: MouseEvent<HTMLCanvasElement>) { if (!contextRef.current || !isDrawing) return; const { offsetX: x, offsetY: y } = nativeEvent; const ctx = contextRef.current; ctx.lineTo(x, y); ctx.stroke(); setPath((prevPath) => [...prevPath, { x, y }]); } return ( <canvas ref={canvasRef} onMouseDown={startDrawing} onMouseUp={finishDrawing} onMouseLeave={finishDrawing} onMouseMove={onDraw} style={{ backgroundColor: "#FFF", borderRadius: "16px", border: "1px solid black" }} /> ); }
关键修改说明
- 路径抽稀:通过
simplifyPath函数去除冗余点,保留路径核心特征,降低后续计算复杂度 - 自适应滤波:根据点间距判断绘制速度,动态调整滤波窗口大小,快速绘制时缩小窗口,避免丢失顶点细节
- 斜率检测:通过相邻点斜率的正负变化识别峰值/谷值,逻辑更稳定,不受点密度影响
- 真实顶点定位:在检测到疑似顶点后,在局部范围内查找原始路径的真实极值点,保证标记位置准确
内容的提问来源于stack exchange,提问作者Somebody
相关产品推荐
相关产品推荐

