konva绘制图像标定

1,826 阅读4分钟

背景

项目上需要使用一些绘制图形的交互,用于给摄像机添加标定,当行人或者车辆入侵时,自动出触发报警。

同时在仓库地图上,动态标定相机位置。实时查看相机状态。

需求

  1. 视频流添加标定工具:矩形与多边形。
  2. 仓库地图编辑,增加相机位置绑定。

效果

konva绘制矩形多边形

技术栈

项目:react@18.2.0,turbo@1.9.8,antd@5.XX, pro-component;

插件:"konva": "^9.0.1","react-konva": "^18.2.7", "react-konva-utils": "^1.0.4";

此处介绍项目框架只是为什么更好的在讲解过程中,梳理结构与思路,插件部分的使用版本不同,api也会有所不同,所以,建议根据自己所有版本,查看api。

项目采用turbo + npmprepo的方式开发,简单介绍下项目结构,便于下文理解。

...
├─package.json
├─pnpm-workspace.yaml
├─turbo.json
├─packages
|    ├─ui
|    | ├─.eslintrc.js
|    | ├─index.ts
|    | ├─package.json
|    | ├─tsconfig.json
|    | ├─components
|    | |     └konva
|    | |     ├─Rect.tsx
|    | |     ├─Polygon.tsx
|    | |     └Stage.tsx        
|    ...
├─apps
...
|  ├─admin
|  |   ├─.eslintrc.js
|  |   ├─index.html
|  |   ├─package.json
|  |   ├─tsconfig.json
|  |   ├─vite.config.ts
|  |   ├─src
|  |   |  ...
|  |   |  ├─pages
|  |   |  |   ...
|  |   |  |   ├─dashboard
|  |   |  |   |     ├─index.tsx
|  |   |  |   |     └style.module.scss
...

将业务系统与组件拆分,使组件开发变得更纯粹和快乐。

结构与真实项目并不一致,此处只是为了讲解做了删减。

绘制矩形与多边形

Stage场景组件

packages => ui =>components 中开发组件,所有涉及到konva绘制的依赖,我们都只会在ui的模块中添加。 下面,我们就来实现一个场景组件的开发,包含功能:

  1. 组件入参定义
  2. stage初始化
  3. Rect组件开发
  4. polygon组件开发以及回调用的实现。

20230520-102944.gif 创建一个 Stage.tsx组件

import Konva from 'konva';
import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Stage, Layer, Rect, Image } from 'react-konva';

import KonvaRect from './konva/Rect';

import KonvaPolygon from './konva/PolygonLine';

interface KonvaStageProps {
  width: number;
  height: number;
  children?: React.ReactNode;
  imgSrc?: string;
  // Promise函数 drawEndFn
  drawEndFn?: (data: Record<string, any>) => Promise<boolean>;
  // 初始矩形数据
  initialRectData?: Record<string, any>[];
}

const KonvaStage = (props: KonvaStageProps, ref) => {
  const {
    width = 500,
    height = 500,
    children,
    imgSrc,
    drawEndFn = () => Promise.resolve(true),
    initialRectData = [],
  } = props;
  const stageRef = useRef<Konva.Stage>(null);
  const [SinglePoint, setSinglePoint] = useState();
  const [drawRact, setdrawRact] = useState(false);
  const [editable, seteditable] = useState(false);

  // 画多边形的状态管理
  const [drawPolygon, setdrawPolygon] = useState(false);
  const [polygonEditable, setPolygonEditable] = useState(false);

  // 处理加载图片
  const [loadImage, setloadImage] = useState(null);
  const [ImgeSize, setImgeSize] = useState<Record<string, any>>({});

  const drawFn = () => {
    setdrawRact(true);
    seteditable(false);
    setdrawPolygon(false);
    setPolygonEditable(false);
  };

  const editFn = () => {
    setdrawRact(false);
    seteditable(true);
  };

  const drawPolygonFn = () => {
    setdrawRact(false);
    setdrawPolygon(true);
    setPolygonEditable(false);
  };
  const editPolygonFn = () => {
    setPolygonEditable(true);
    setdrawPolygon(false);
  };
  /**
   * 删除单个Rect
   * @param name 矩形名称
   */
  const deleteRect = (name: string) => {
    const stage = stageRef.current;
    console.log(stage?.getStage().getChildren());

    const layer = stage?.getStage().findOne('.rect-layer');
    const rect = layer?.findOne(`.${name}`);
    rect?.destroy();
    stage?.getStage().draw();
  };

  /**
   * 删除名为name的图形组件
   * @param name 图形名称
   * @param callback 回调函数
   */
  const deleteOneShape = (
    name: string,
    callback: (data: Record<string, any>) => Promise<boolean>
  ) => {
    return new Promise((resolve, reject) => {
      const stage = stageRef.current;
      const nodes = stage?.getStage().find('Rect, Line');
      nodes?.filter((node) => {
        if (node.name() === name) {
          node.destroy();
          callback && callback({ name });
          resolve(true);
        }
        reject(false);
      });
    });
  };

  /**
   *
   * @returns 删除多边形
   */
  const deletePolygon = (name: string) => {
    const stage = stageRef.current;
    const layer = stage?.getStage().findOne('.polygon-layer');
    // 打印layer下的所有节点
    console.log(layer?.getChildren());
    const polygon = layer?.findOne(`.${name}`);
    polygon?.destroy();
    stage?.getStage().draw();
  };
  // 获取矩形数据
  const getRectData = () => {
    const stage = stageRef.current;
    const layer = stage?.getStage().find('.rect-layer')[0];
    const rects = layer?.find('Rect');
    const data = rects?.filter((rect) => {
      const { x, y, width, height, name } = rect.attrs;
      return (
        name && {
          x,
          y,
          width,
          height,
        }
      );
    });
    return data;
  };

  // 清空.layer-rect下的所有矩形
  const clearAll = (shapes: string[]) => {
    const stage = stageRef.current;
    stage
      ?.getStage()
      .find('Rect, Line')
      .filter((node) => {
        const shape = node.name();

        if (shape.includes('rect') || shape.includes('polygon')) {
          node.destroy();
        }
      });
    stage?.getStage().draw();
  };

  useImperativeHandle(ref, () => ({
    draw: drawFn,
    edit: editFn,
    getRectData,
    clearAll,
    deleteRect,
    drawPolygonFn,
    editPolygonFn,
    deletePolygon,
    deleteOneShape,
  }));
  // 监听imageSrc变化
  useEffect(() => {
    if (imgSrc) {
      const image = new window.Image();
      image.src = imgSrc;
      image.onload = function () {
        const { width, height } = this;
        setImgeSize({
          width,
          height,
        });

        setloadImage(image);
      };
    }
  }, [imgSrc]);

  return (
    <React.Fragment>
      <Stage width={width} height={height} ref={stageRef}>
        <Layer>
          {loadImage && (
            <Image image={loadImage} width={ImgeSize.width} height={ImgeSize.height} x={0} y={0} />
          )}
        </Layer>
        <Layer name="rect-layer">
          <KonvaRect
            drawable={drawRact}
            callback={(data) => {
              setSinglePoint({ ...data });
            }}
            initialRectData={initialRectData}
            drawEndFn={drawEndFn}
            editable={editable}
          />
        </Layer>
        <Layer name="polygon-layer">
          <KonvaPolygon
            drawable={drawPolygon}
            callback={(data) => {
              // setSinglePoint({ ...data });
            }}
            initialRectData={initialRectData}
            drawEndFn={drawEndFn}
            editable={polygonEditable}
          />
        </Layer>
      </Stage>
      <div style={{ display: 'flex', justifyContent: 'space-between' }}>
        <p>x: {SinglePoint?.x}</p>
        <p>y: {SinglePoint?.y}</p>
        <p>width: {SinglePoint?.width}</p>
        <p>height: : {SinglePoint?.height}</p>
      </div>
    </React.Fragment>
  );
};

export default KonvaStage;

参数说明

  width: number;
  height: number;
  children?: React.ReactNode;
  imgSrc?: string;
  // Promise函数 drawEndFn
  drawEndFn?: (data: Record<string, any>) => Promise<boolean>;
  // 初始矩形数据
  initialRectData?: Record<string, any>[];

场景的大小必须是数字,这是由konva的参数决定。我们需要将视频截取一帧,作为区域编辑的地图,所以,我们需要将图片的参数 imgSrc传入。

deawEndFn的主要作用,是为了在绘制完图形之后,我们需要在调用组件的地方,做自定义扩展,比如,我们在绘制结束之后,需要弹窗提示是否需要保存当前绘制图形。

initialRectData的作用是为了编辑使用带入初始化图形数据。

stage初始化

<Stage width={width} height={height} ref={stageRef}>
    <Layer>
      {loadImage && (
        <Image image={loadImage} width={ImgeSize.width} height={ImgeSize.height} x={0} y={0} />
      )}
    </Layer>
    <Layer name="rect-layer">
      <KonvaRect
        drawable={drawRact}
        callback={(data) => {
          setSinglePoint({ ...data });
        }}
        initialRectData={initialRectData}
        drawEndFn={drawEndFn}
        editable={editable}
      />
    </Layer>
    <Layer name="polygon-layer">
      <KonvaPolygon
        drawable={drawPolygon}
        callback={(data) => {
          // setSinglePoint({ ...data });
        }}
        initialRectData={initialRectData}
        drawEndFn={drawEndFn}
        editable={polygonEditable}
      />
    </Layer>
</Stage>

重点: 所有的操作不与Stage层绑定,如移动、缩放、事件等,确保整个场景纯真,这里分了三个Layer图层,分别实现:图片绘制、矩形绘制与多边形绘制。

Rect多边形组件开发


import { animated, useSpring } from '@react-spring/konva';
import React, { useEffect, useRef, useState } from 'react';
import { Rect, Transformer } from 'react-konva';
import { generateUUID } from 'utils';
const KonvaRect = (props: KonvaRectProps) => {
  const { drawable = false, editable = false, callback, drawEndFn, initialRectData = [] } = props;
  const [rectangles, setRectangles] = useState<Record<string, any>[]>(initialRectData);
  const [drawing, setDrawing] = useState(false);
  const [startPos, setStartPos] = useState({ x: 0, y: 0 });
  const [endPos, setEndPos] = useState({ x: 0, y: 0 });
  const [isHovered, setIsHovered] = useState<boolean>(false);
  const [isSelected, setIsSelected] = useState(false);
  const trRef = useRef(null);

  const [RectNode, setRectNode] = useState();

  const circleAnimation = useSpring({
    scale: isHovered ? 1.5 : 1,
    opacity: isHovered ? 0.8 : 0.5,
    config: { tension: 20, friction: 10 }, // 可根据需求调整动画效果
  });

  // 设置animated动画自动执行
  useEffect(() => {
    const animate = setInterval(() => {
      setIsHovered((flat) => !flat);
    }, 800);
    // 销毁requestAnimationFrame
    return () => clearInterval(animate);
  }, []);

  const handleMouseDown = (event) => {
    setDrawing(true);
    setStartPos({ x: event.evt.layerX, y: event.evt.layerY });
    setEndPos({ x: event.evt.layerX, y: event.evt.layerY });
  };

  const handleMouseMove = (event) => {
    if (drawing) {
      setEndPos({ x: event.evt.layerX, y: event.evt.layerY });
    }
  };

  //处理正在画的rect x、y、width、height 执行callback
  useEffect(() => {
    if (drawing) {
      callback &&
        callback({
          x: Math.min(startPos.x, endPos.x),
          y: Math.min(startPos.y, endPos.y),
          width: Math.abs(endPos.x - startPos.x),
          height: Math.abs(endPos.y - startPos.y),
        });
    }
  }, [endPos.x, endPos.y]);

  const handleMouseUp = async () => {
    const rectData = {
      x: Math.min(startPos.x, endPos.x),
      y: Math.min(startPos.y, endPos.y),
      width: Math.abs(endPos.x - startPos.x),
      height: Math.abs(endPos.y - startPos.y),
      name: `rect-${generateUUID()}`,
    };
    // 如果没有运笔,直接返回
    if (rectData.width < 10 || rectData.height < 10) {
      setDrawing(false);
      return;
    }
    const flat = await drawEndFn(rectData);
    if (flat) {
      setRectangles([...rectangles, { ...rectData }]);
    }
    setDrawing(false);
  };

  const handleOnClick = (e) => {
    setRectNode(e.target);
    setIsSelected(true);
  };

  const [MenuVisible, setMenuVisible] = useState(false);
  const [MenuPos, setMemuPos] = useState({ x: 0, y: 0 });
  const handleContextMenu = (e) => {
    e.evt.preventDefault();
    handleOnClick(e);
    setMenuVisible(true);
    setMemuPos({ x: e.evt.layerX, y: e.evt.layerY });
  };

  const handleDeleteRect = () => {
    const newRectangles = rectangles.filter((rect) => rect.name !== 'rect');
    setRectangles(newRectangles);
    setMenuVisible(false);
  };

  return (
    <React.Fragment>
      {MenuVisible && (
        <div
          style={{
            position: 'fixed',
            left: MenuPos.x,
            top: MenuPos.y,
            backgroundColor: 'white',
            boxShadow: '0 0 5px gray',
            padding: '5px',
          }}
        >
          <div onClick={handleDeleteRect}>Delete</div>
        </div>
      )}
      {endPos.x !== 0 && drawable && (
        <animated.Circle
          x={endPos.x}
          y={endPos.y}
          radius={5}
          fill="yellow"
          opacity={circleAnimation.opacity}
          scaleX={circleAnimation.scale}
          scaleY={circleAnimation.scale}
        />
      )}
      {rectangles.map((rect, i) => (
        <Rect
          key={i}
          x={rect.x}
          y={rect.y}
          name={rect.name}
          width={rect.width}
          height={rect.height}
          stroke="red"
          strokeWidth={2}
          fill="rgba(255, 255, 255, 0.2)"
          shadowColor="black"
          shadowBlur={10}
          shadowOpacity={0.5}
          shadowOffsetX={2}
          shadowOffsetY={2}
          draggable={editable}
          onClick={handleOnClick}
          onDragMove={(e) => {
            callback && callback(e.target.attrs);
          }}
          onContextMenu={handleContextMenu}
          onMouseOver={(e) => {
            // 设置背景颜色加深,阴影加深
            e.target.shadowBlur(20);
            // 修改背景颜色
            e.target.fill('rgba(255, 194, 16, 0.2)');
          }}
          onMouseLeave={(e) => {
            e.target.shadowBlur(10);
            e.target.fill('rgba(255, 255, 255, 0.2)');
          }}
          onTransformEnd={(e) => {
            const node = e.target;
            const scaleX = node.scaleX();
            const scaleY = node.scaleY();

            node.width(node.width() * scaleX);
            node.height(node.height() * scaleY);
            node.scaleX(1);
            node.scaleY(1);
            const transformer = trRef.current;
            transformer.forceUpdate();
            callback && callback(node.attrs);
          }}
        />
      ))}

      {drawing && (
        <Rect
          x={Math.min(startPos.x, endPos.x)}
          y={Math.min(startPos.y, endPos.y)}
          width={Math.abs(endPos.x - startPos.x)}
          height={Math.abs(endPos.y - startPos.y)}
          stroke="yellow"
          strokeWidth={2}
          fill="rgba(255, 255, 205, 0.5)"
          shadowColor="black"
          shadowBlur={10}
          shadowOpacity={0.3}
          shadowOffsetX={5}
          shadowOffsetY={5}
        />
      )}
      {drawable && (
        <Rect
          x={0}
          y={0}
          width={window.innerWidth}
          height={window.innerHeight}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          onMouseUp={handleMouseUp}
        />
      )}

      {isSelected && editable && (
        <Transformer
          ref={(node) => {
            if (node !== null) node?.getLayer().batchDraw();
          }}
          node={RectNode}
          onTransformEnd={(e) => {
            const node = e.target.node();
            const scaleX = node.scaleX();
            const scaleY = node.scaleY();
            const x = node.x();
            const y = node.y();

            // set line position
            node.setPosition({
              x: x * scaleX,
              y: y * scaleY,
            });
          }}
        />
      )}
    </React.Fragment>
  );
};

export default KonvaRect;

同样,我们介绍下入参的定义。

interface KonvaRectProps {
  drawable?: boolean; // 是否可绘制
  editable?: boolean; // 是否可编辑
  callback?: (rectangles: Record<string, any>[]) => void; // 回调函数
  drawEndFn: (data: Record<string, any>) => Promise<any>; // Promise函数 drawEndFn
  initialRectData?: Record<string, any>[]; // 初始矩形数据
}

这里有个小的操作细节,我们不将事件绑定在Stage中,但我们在Rect组件中,申明了一个宽高与外层容易一样的Rect,将事件绑定在Rect图形上,这样,就相当于给整个场景,添加了一层事件蒙版,所有的操作都在蒙版上面进行。确保与其他操作分离。

polygon的绘制思路与Rect基本一致,可参考绘制Rect矩形的代码参考,效果如下:

20230520-111811.gif

这里使用到 react-konva-utilsHtml组件,它可以允许我们讲html代码片段植入到场景中,使我们在编写业务的时候,多了一种可玩性。

<Stage width={width} height={height} ref={stageRef} draggable onWheel={handleWheel}>
    <Layer>
      {loadImage && (
        <Image
          image={loadImage}
          width={ImgeSize.width}
          height={ImgeSize.height}
          onContextMenu={handleDblClick}
        />
      )}
      {cameraList.map((item) => {
        return (
          <Html
            divProps={{
              style: {
                top: `${(item.y - 20) * scale}px`,
                left: `${(item.x - 20) * scale}px`,
              },
            }}
          >
            {props.children}
          </Html>
        );
      })}
    </Layer>
 </Stage>

PS

目前阶段功能还没开发完,很多代码还没有与实际业务场景融合,分享给大家作为一个参考,欢迎来扰👻