원래는 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를 같이 설치해야 사용 가능합니다.
이제 본격적으로 코드를 작성해봅시다!
이번 챕터에서는 폴리곤을 그리고 생성하는 과정을 진행하겠습니다.
전체적인 영역 구성은 드로우 영역, 버튼 영역, 그리고 라벨 영역으로 나눠집니다.
// 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;
이제 폴리곤을 생성하기 전에 코드를 작성하고, 함수에 대해 설명드리겠습니다.
// 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;
// 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;
// ...
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>
)
챕터 - 1 완성본
이번 챕터에선 가장 기본적인 라벨을 그리고 생성하는 기능을 만들어봤습니다.
다음 챕터에서는 라벨을 움직이고, 크기를 변경할 수 있는 라벨 업데이트 기능들을 만들어보겠습니다.
완성된 코드는 깃허브에서 확인하실 수 있습니다.
주소
다음 챕터에서 뵙겠습니다.