You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何修复Canvas绘图时快速绘制导致的顶点检测错误?

问题描述

我开发了一个React Canvas绘图应用,需要识别绘制图形的峰值(最高点)和谷值(最低点)。慢速绘制时检测结果正确,但快速或突然绘制时,顶点识别出现错误。

慢速绘制示例:
慢速绘制示例

快速绘制错误示例:
快速绘制错误示例

问题根源

快速绘制时鼠标移动事件触发间隔变大,采集到的点密度降低,原实现存在以下问题:

  • 固定大小的移动平均滤波窗口在点稀疏时过度平滑,丢失真实顶点特征
  • 依赖当前窗口极值的阈值判断逻辑,在点间距大时容易误判局部波动为顶点
  • 未考虑绘制速度(点间距)对检测逻辑的影响,直接按索引划分窗口不合理
解决方案

通过以下三点优化修复问题:

  1. 路径抽稀:去除冗余点,保留路径关键特征,减少计算量
  2. 自适应滤波:根据绘制速度动态调整滤波窗口大小,避免过度平滑
  3. 斜率变化检测:通过相邻点斜率变化识别顶点,替代原阈值逻辑,提升稳定性
修改后的完整代码
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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.26 18:07:09