[KonvaJS] 데이터 라벨링 툴 만들기 챕터 - 1

emit·2024년 1월 12일
5
post-thumbnail

📌 시작하기에 앞서

원래는 1주일 간격으로 Konva.js에 대해 소개, 실습 프로젝트를 단계별로 작성하려고 했습니다.
정말 안타깝게도 TF팀에 들어가게되면서 전혀 시간이 나질 않았습니다. 핑계가 아님.(엄근진)

다행히도 현재는 어느정도 여유가 생겼고, 다시 이 포스트 시리즈를 이어가려고 합니다.

저번 글에서 언급했듯이 실전 프로젝트를 준비했습니다. react-konva(konva의 react 호환)를 사용해 데이터 라벨링 툴을 만들어보려고 합니다.

모르시는 분들을 위해

데이터 라벨링이란
이미지나 영상, 텍스트, 오디오 등 데이터를 인공지능이 학습할 수 있도록 다양한 정보를 목적에 맞게 입력하는 작업

cvat.ai, Labelbox 같은 툴이다.

위 툴 기능 중에 기본적인 Polygon을 만들어볼 수 있도록 진행하면서 이 글을 보는 분들이 konva에 대해 더 친숙해지도록 하는 게 저의 바램입니다.

🪄 개발 환경 세팅하기

저는 일단 Window 10 + npm@8.3.0 + node@18.16.1 환경에서 시작했습니다.

tailwindcss 를 사용하기 위해
https://tailwindcss.com/docs/guides/vite 를 참고해서 vite + tailwindcss 설정 및 설치했습니다.(css를 사용하셔도 됩니다!)

npm install konva react-konva uuid

react-konva 사용하기 위해 konva + react-konva를 설치합니다
konva를 같이 설치해야 사용 가능합니다.

🧊 챕터-1 시작하기 (코드 작성)

이제 본격적으로 코드를 작성해봅시다!

이번 챕터에서는 폴리곤을 그리고 생성하는 과정을 진행하겠습니다.

전체적인 영역 구성은 드로우 영역, 버튼 영역, 그리고 라벨 영역으로 나눠집니다.

// src/App.jsx

import { useEffect, useRef, useState } from "react";
import { Layer, Stage } from "react-konva";

function App() {
  const [canvSize, setCanvSize] = useState({ canvWidth: 0, canvHeight: 0 });

  const areaRef = useRef();
  const stageRef = useRef();
  const layerRef = useRef();

  const handleResizeCanvas = () => {
    if (!areaRef.current) {
      return;
    }
    const { clientWidth, clientHeight } = areaRef.current;
    setCanvSize({
      canvWidth: clientWidth,
      canvHeight: clientHeight,
    });
  };

  // 초기 canvas size
  useEffect(() => {
    if (!areaRef.current) {
      return;
    }
    const { clientWidth, clientHeight } = areaRef.current;
    setCanvSize({
      canvWidth: clientWidth,
      canvHeight: clientHeight,
    });
  }, []);

  // resize canvas size
  useEffect(() => {
    window.addEventListener("resize", handleResizeCanvas);
    return () => {
      window.removeEventListener("resize", handleResizeCanvas);
    };
  }, []);

  return (
    <div className="flex flex-col w-screen h-screen">
      <div className="flex flex-row w-full h-full basis-7/8">
        <div className="w-4/5 bg-gray-200" ref={areaRef}>
          <Stage
            ref={stageRef}
            width={canvSize.canvWidth}
            height={canvSize.canvHeight}
          >
            <Layer ref={layerRef}></Layer>
          </Stage>
        </div>
        <div className="w-1/5">
          <button className="px-4 py-2 m-2 text-white transition duration-500 bg-indigo-500 border border-indigo-500 rounded-md select-none ease hover:bg-indigo-600 focus:outline-none focus:shadow-outline">
            폴리곤 생성하기
          </button>
        </div>
      </div>
    </div>
  );
}

export default App;
  • 드로우 영역과 버튼 + 라벨 목록 영역을 구분하기 위해 각각의 요소를 사용합니다.
  • 드로우 영역은 Stage를 사용하여 Layer를 생성해야 합니다.
  • 이전 글에서 설명한 것처럼, Stage는 실제로 그래픽 요소가 그려지는 영역이며, 캔버스 크기를 정의해야 합니다.
    • 캔버스 크기는 canvSize라는 상태 객체에 width와 height로 정의해야 합니다.
    • 또한, 리사이즈가 발생할 때마다 width와 height가 변경되도록 작성해야 합니다.
  • 각각의 영역 요소, stage, layer를 참조하기 위해 ref 객체로 정의합니다.

이제 폴리곤을 생성하기 전에 코드를 작성하고, 함수에 대해 설명드리겠습니다.

// src/App.jsx
import { useCallback, useEffect, useRef, useState } from "react";
import { Layer, Line, Rect, Stage } from "react-konva";

function App() {
  // ...
  const [points, setPoints] = useState([]); // 생성할 때 저장되는 point[[x, y], [x, y], ...]
  const [curMousePos, setCurMousePos] = useState([0, 0]);
  const [isFinished, setIsFinished] = useState(true);
  const [isMouseOverStartPoint, setIsMouseOverStartPoint] = useState(false);
  
  // ...

  const handleStartDraw = useCallback(() => {
    setIsFinished(false);
  }, []);

  // Stage 절대 포인트
  const getMousePos = (stage) => {
    return [stage.getPointerPosition().x, stage.getPointerPosition().y];
  };

  const handleMouseDown = (e) => {
    const stage = e.target.getStage(); // stage 가져오기
    const mousePos = getMousePos(stage); // 현재 마우스의 x, y값 가져오기

    // 라벨 생성전에는 이벤트 막기
    if (isFinished) {
      return;
    }

    // 첫번째 포인트를 찍고 points가 3개 이상일 때 라벨 드로우 중지
    if (isMouseOverStartPoint && points.length >= 3) {
      setIsFinished(true);

      setPoints([]);
    } else {
      // 아니면 points 업데이트
      setPoints([...points, mousePos]);
    }
  };

  // 현재 마우스 points 계산 - 
  const handleMouseMove = (e) => {
    const stage = e.target.getStage();
    const mousePos = getMousePos(stage);

    setCurMousePos(mousePos);
  };

  // 첫번째 점 커지게 만들기.
  const handleMouseOverStartPoint = (e) => {
    if (isFinished || points.length < 3) return;
    e.target.scale({ x: 2, y: 2 });
    setIsMouseOverStartPoint(true);
  };

  // 첫번째 점 작게 만들기
  const handleMouseOutStartPoint = (e) => {
    e.target.scale({ x: 1, y: 1 });
    setIsMouseOverStartPoint(false);
  };

  // 현재 포인트 flatten [[x, y], [x, y]] => [x, y, x, y]
  const flattenedPoints = points
    .concat(isFinished ? [] : curMousePos)
    .reduce((a, b) => a.concat(b), []);

  return (
    <div className="flex flex-col w-screen h-screen">
      <div className="flex flex-row w-full h-full basis-7/8">
        <div className="w-4/5 bg-gray-200" ref={areaRef}>
          <Stage
            ref={stageRef}
            width={canvSize.canvWidth}
            height={canvSize.canvHeight}
            onMouseDown={handleMouseDown}
            onMouseMove={handleMouseMove}
          >
            <Layer ref={layerRef}>
              <Line
                points={flattenedPoints}
                stroke="black"
                dash={[5, 5]}
                strokeWidth={3}
                closed={isFinished}
              />
              {points.map((point, index) => {
                const width = 7;
                const x = point[0] - width / 2;
                const y = point[1] - width / 2;

                // point값이 시작점일 때만
                const startPointAttr =
                  index === 0
                    ? {
                        hitStrokeWidth: 12,
                        onMouseOver: handleMouseOverStartPoint,
                        onMouseOut: handleMouseOutStartPoint,
                      }
                    : null;
                return (
                  <Rect
                    key={index}
                    x={x}
                    y={y}
                    width={width}
                    height={width}
                    fill="red"
                    stroke="yellow"
                    strokeWidth={1}
                    {...startPointAttr}
                  />
                );
              })}
            </Layer>
          </Stage>
        </div>
        <div className="w-1/5">
          <button
            className="px-4 py-2 m-2 text-white transition duration-500 bg-indigo-500 border border-indigo-500 rounded-md select-none ease hover:bg-indigo-600 focus:outline-none focus:shadow-outline"
            onClick={handleStartDraw}
          >
            폴리곤 생성하기
          </button>
        </div>
      </div>
    </div>
  );
}

export default App;
  • handleStartDraw 함수를 사용하여 폴리곤 생성을 시작합니다.
  • getMousePos 함수는 stage의 x, y 값을 가져오는 함수입니다.
  • handleMouseDown 함수는 stage 컴포넌트에 연결되어 있으며, 이벤트 활성화 여부를 isFinished로 판단합니다.
    • 첫 번째 포인트를 찍고, 현재 포인트가 3개 이상일 때 폴리곤 생성 이벤트를 중단하고 현재 생성 포인트를 초기화합니다. 그렇지 않으면 현재 포인트를 업데이트합니다.
  • handleMouseMove 함수는 현재 마우스 포인트의 x, y 값을 업데이트합니다.
    • 이 함수도 stage 컴포넌트에 연결되어 있으며, getMousePos 함수와 setCurMousePos를 사용하여 x, y 값을 업데이트합니다.
// src/App.jsx

// ...
<Line
  points={flattenedPoints}
  stroke="black"
  dash={[5, 5]}
  strokeWidth={3}
  closed={isFinished}
/>
  {points.map((point, index) => {
    const width = 7;
    const x = point[0] - width / 2;
    const y = point[1] - width / 2;

    // point값이 시작점일 때만
    const startPointAttr =
     index === 0 ? {
      hitStrokeWidth: 12,
      onMouseOver: handleMouseOverStartPoint,
      onMouseOut: handleMouseOutStartPoint,
    } : null;
    
    return (
      <Rect
        key={index}
        x={x}
        y={y}
        width={width}
        height={width}
        fill="red"
        stroke="yellow"
        strokeWidth={1}
        {...startPointAttr}
      />
);
})}
// ...

위의 코드에서는 Layer 컴포넌트 내에 Line과 Rect 컴포넌트를 추가했습니다. Line은 점들이 업데이트될 때 대시 라인이 생성되거나 현재 마우스 포인터를 따라가도록 구현되었습니다.
Line의 points 속성에는 1차원 배열로 값을 넣어주어야 합니다. 따라서 flattenedPoints와 같이 변경하여 넣어주었습니다.

점을 찍을 때마다 Rect를 생성하며, 시작점은 handleMouseOverStartPoint와 handleMouseOutStartPoint 함수와 연결하여 마우스 오버 또는 마우스 아웃 시 스케일이 커지거나 작아지도록 구현되었습니다.

마지막으로 Label을 그려주겠습니다.
src 폴더 내에 components 폴더를 생성해줍니다.

// src/components/LabelTarget.jsx
import { useRef } from "react";
import { Circle, Group, Line } from "react-konva";

// target에는 해당 포인트의 정보가 있습니다.
const LabelTarget = ({ target }) => {
  const targetRef = useRef();

  // 2차원 포인트 배열 1차원 포인트 배열로 만들기
  const flattedendPoints = target.points
    ? target.points.reduce((a, b) => a.concat(b), [])
    : [];

  return (
    <>
      <Group ref={targetRef} draggable={false}>
        <Line
          points={flattedendPoints}
          stroke="black"
          strokeWidth={1}
          lineJoin="round"
          fill="red"
          closed={true}
          opacity={0.5}
        />
        {target.points &&
          target.points.map((point, index) => {
            return (
              <Circle
                key={index}
                x={point[0]}
                y={point[1]}
                radius={5}
                fill="yellow"
                stroke="yellow"
                strokeWidth={0.1}
                draggable={false}
              />
            );
          })}
      </Group>
    </>
  );
};

export default LabelTarget;
  • 라벨을 만들기 위해 Konva에서 다각형을 그리려면 Line을 사용하여 3개 이상의 선을 그리고 closed 속성을 true로 설정하면 됩니다. 이렇게 하면 선들이 연결되어 다각형이 형성됩니다. 또한, 다각형의 내부를 채우기 위해 fill 속성에 원하는 색상을 지정해야 합니다.
  • 라벨의 꼭짓점에는 Circle을 사용하여 점을 그려야 합니다. 이를 위해 각 꼭짓점의 좌표를 지정하고, 해당 좌표에 Circle을 생성하여 그려주면 됩니다.
  • Line과 Circle을 Group으로 묶어서 라벨을 완성할 수 있습니다. Group은 Konva에서 제공하는 클래스로, 여러 개의 도형이나 객체를 그룹화하여 관리할 수 있습니다.
    • 따라서 Line과 Circle을 Group으로 묶어서 라벨을 만들면 관련된 요소들을 한 번에 제어하고 조작할 수 있습니다.
// ...
import { v4 as uuidv4 } from "uuid"; // target id를 위해
import LabelTarget from "./components/LabelTarget";  // LabelTarget 컴포넌트 불러오기

function App() {
  // ...
  const [labelObjList, setLabelObjList] = useState([]); // 라벨 정보 리스트
  
  const handleMouseDown = (e) => {
    const stage = e.target.getStage();
    const mousePos = getMousePos(stage);

    // 라벨 생성전에는 이벤트 막기
    if (isFinished) {
      return;
    }

    if (isMouseOverStartPoint && points.length >= 3) {
      // 완성 이벤트
      setIsFinished(true);

      // 완성된 target의 id와 points 정보
      const target = {
        id: uuidv4(),
        points: points,
      };

      setLabelObjList([...labelObjList, target]);

      setPoints([]);
    } else {
      // points 업데이트
      setPoints([...points, mousePos]);
    }
  };
  
  return (
  <Layer>
    // ...
    {/* 만들어진 폴리곤 라벨리스트 */}
    {labelObjList.length > 0 &&
      labelObjList.map((target) => (
       <LabelTarget key={target.id} target={target} />
      ))}
  </Layer>
  )
  • 완성 이벤트 부분에서 target의 정보에 id와 현재 그린 points를 넣어주어야 합니다. 이를 위해 target 객체에 id와 points 속성을 추가하고, 해당 값을 할당합니다.
  • 그리고 현재 라벨 리스트에 target을 추가해야 합니다. 라벨 리스트는 배열로 구성되어 있으므로, 스프레드 연산자를 사용하여 target을 라벨 리스트에 추가합니다.
  • 또한, labelObjList을 map() 메서드를 활용하여 LabelTarget에 target 값을 넣어주어야 합니다.
  • labelObjList.map()을 사용하여 LabelTarget에 target 값을 할당하고, 이를 새로운 배열로 업데이트합니다

챕터 - 1 완성본

글을 마치며

이번 챕터에선 가장 기본적인 라벨을 그리고 생성하는 기능을 만들어봤습니다.
다음 챕터에서는 라벨을 움직이고, 크기를 변경할 수 있는 라벨 업데이트 기능들을 만들어보겠습니다.

완성된 코드는 깃허브에서 확인하실 수 있습니다.
주소

다음 챕터에서 뵙겠습니다.

profile
간단한 공부 기록들 https://github.com/ohjooyeong

0개의 댓글