Canvas还能这么玩?(Canvas实现图片标注)

6,685 阅读3分钟

我的Canvas入门记录,利用Canvas实现图片标注

title.png

大致需求

服务端返回图片地址,前端需要加载图片并在其上进行标注区域,然后把点位数组返回给后端。 一图流效果如下: 20220309_142319.gif

实现过程

绘制底图

首先需要把后端返回的图片地址,渲染在Canvas当中(这里有2个Canvas标签,一个是绘制底图, 一个是绘制标注图层,需要保证2个Canvas宽高一致,利用CSS使标注图层覆盖在底图之上), 利用React useEffect监听props里的src改变,从而渲染底图。由于不知道图片的大小,为了图片渲染出来比例一样,所以需要根据Canvas宽高与图片宽高来计算缩放比例(用useRef进行了存储,方便后面计算坐标点位)。

 useEffect(() => {
        if(!src) return;
        const ctx = baseCanvas.current.getContext("2d");
        setLoading(true);
        //图片加载完后,将其绘制在canvas中
        const img = new Image();
        img.src = src;
        img.onload = function () {
            ctx.clearRect(0, 0, width, height); // 清除底图
            let _width = img.width, _height = img.height;
            imgInfo.current = {
                width: _width,
                height: _height
            } // 存储图片宽高 防止计算导致的误差
            if (img.width > width || img.height > height) {
                scale.current = img.width > img.height ? img.width / width : img.height / height;
                _width = img.width / scale.current;
                _height = img.height / scale.current;
            } // 等比例缩放
            baseCanvas.current.width = topCanvas.current.width = _width;
            baseCanvas.current.height = topCanvas.current.height = _height;
            ctx.drawImage(this, 0, 0, _width, _height);
            setLoading(false);
            handleClear(); // 清除标注
        }
        img.onerror = () => {
            warn_barry('图片加载失败');
            setLoading(false);
        }
    }, [src]);

好了,到这一步,已经差不多完成了对吧。可以摸2天鱼,剩下的周五再说,手动狗头。

QQ图片20220310111841.gif

描点与连线

定义一个point数组,用来存储每次新增标注的点位信息。 在Canvas上绑定点击事件,点击之后在标注图层进行描点,将该点与point数组里的最后一个点位信息进行连线,再把信息存入point数组。

let points: Array<point> = [];
const clickFn = (e: any) => {
                if (brush !== 'add') return;
                clearTimeout(timer);
                timer = setTimeout(() => {
                    let x = e.offsetX, y = e.offsetY;
                    const index = points.findIndex(item => Math.abs(equalScale(item[0], false) - x) < 10 && Math.abs(equalScale(item[1], false) - y) < 10);
                    if (index > 0) { console.error('请连接起点'); return; }
                    if (index === 0) { x = equalScale(points[0][0], false); y = equalScale(points[0][1], false); }
                    const ctx = topCanvas.current.getContext("2d");
                    ctx.fillStyle = "rgba(24, 144, 255, 1)";
                    ctx.fillRect(x - 3, y - 3, 6, 6);
                    let prevPoint;
                    if (prevPoint = points[points.length - 1]) {
                        ctx.strokeStyle = "rgba(24, 144, 255, 1)";
                        ctx.beginPath();
                        ctx.moveTo(equalScale(prevPoint[0], false), equalScale(prevPoint[1], false));
                        ctx.lineTo(x, y);
                        ctx.stroke();
                        ctx.closePath();
                    }
                    points.push([equalScale(x), equalScale(y)]);
                }, 200);
            } // 单击绘制坐标点及连线
 topCanvas.current.addEventListener('click', clickFn); // 给标注图层绑定点击事件

双击退出新增,绘制标注区域

上面的范围绘制出来之后,提供保存操作(保存之后不允许再次进行描点与连线,只能拖拽节点,改变形状),进行区域绘制。

// 绘制标注方法
 const draw = () => {
        const ctx = topCanvas.current.getContext("2d");
        ctx.clearRect(0, 0, width, height);
        markArr.current.forEach(mark => { // 遍历标注数组,绘制所有的标注区域
            const { points } = mark;
            ctx.beginPath();
            ctx.fillStyle = "rgba(24, 144, 255, 1)";
            points.forEach((item, index) => {
                const x = equalScale(item[0], false), y = equalScale(item[1], false);
                ctx.fillRect(x - 3, y - 3, 6, 6);
                index === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
            });
            ctx.stroke();
            ctx.fillStyle = 'rgba(24, 144, 255, .5)';
            ctx.fill();
            ctx.closePath();
        });
    }
 const dbClickFn = () => {
                clearTimeout(timer);
                if(points.length < 3) {
                    setBrush(''); 
                    return; // 构不成平面则不保存
                }
                if (JSON.stringify(points[0]) !== JSON.stringify(points[points.length - 1])) points.push(points[0]); // 默认连接起点
                markArr.current.push({
                    key: new Date().getTime(),
                    points
                }); // 把临时点位数组存起来
                handleChange(); // 触发props传入的onChange事件
                draw(); // 绘制标注区域
                setBrush(''); // 退出新增状态
            } // 双击保存当前标注

节点拖拽改变标注形状

上面的操作已经成功把标注区域的范围绘制了出来,下面需要进行微调区域,需要实现拖拽节点进行重新绘制。 定义了一个current对象,用来存储当前移动点位。

  • 监听鼠标移动事件,改变current里的点位信息,重新绘制点位以及区域
  • 监听鼠标按下事件,判断当前位置是否与标注点位进行重合,如果重合把该点位信息存入current
  • 监听鼠标抬起事件,清空current对象,触发props里的onChange事件,把最终的点位信息传递出去
            let current: null | point | undefined = null;
            const moveFn = throttle((e: any) => {
                topCanvas.current.style.cursor = 'default';
                const x = e.offsetX, y = e.offsetY;
                if (current) {
                    current[0] = equalScale(x);
                    current[1] = equalScale(y);
                    draw();
                }
            }, 10);
            const mousedownFn = (e: any) => {
                const x = equalScale(e.offsetX), y = equalScale(e.offsetY);
                for (let i = 0; i < markArr.current.length; i++) {
                    const { points } = markArr.current[i];
                    current = points.find(item => Math.abs(item[0] - x) < 20 && Math.abs(item[1] - y) < 20);
                    if(current) break;
                }
            }
            const mouseupFn = () => {
                current = null;
                handleChange();
            }
            topCanvas.current.addEventListener('mousemove', moveFn);
            topCanvas.current.addEventListener('mousedown', mousedownFn);
            topCanvas.current.addEventListener('mouseup', mouseupFn);

到这里,已经差不多完成了。

QQ图片20220310112125.png

json数据双向绑定

现在大致需求已经实现了,但客户又提出了需要可视化json数据,并且能进行更改。

微信图片_20220310111627.jpg

大致意思就是用户不想描点,通过直接改变点位信息来改变形状(很迷惑)。所以就有了右边这一串的json数据,用户可以更改json里面的点位信息来达到绘制区域的效果。由于我把标注组件封装成了一个类似antd Input的效果,它是没有自己的状态的,数据全通过props里的value,onChange来进行更改,唯一要做的就是缓存Canvas元素,避免不必要的渲染,每次新增标注完成和拖拽完成再触发props里的onChange,切忌在拖拽过程中去调用onChange,你可能会发现页面很卡。

QQ图片20220310105519.png

参考案例

wanglin2.github.io/zh/markjs/#… wanglin2.github.io/zh/markjs/#…

拜拜,下篇文章见。

QQ图片20220310112624.jpg