본문 바로가기

Dev-FE

[WEB] #4. 캔버스 이미지 처리

캔버스(htmlCanvasElement)에 이미지 업로드 후, 특정영역에 클릭이벤트를 주는 기능을 구현하고자 한다.
openLayers를 통해서만 활용해 봤을 뿐, 실제로 <canvas /> 태그를 직접 사용해 본 경험이 없어 하나씩 작업해 볼 예정이다.
file upload 를 통해 이미지업로드 후, 이미지정보를 기반으로 캔버스에 비율에 맞춰 도식화한다.

캔버스 이미지 업로드 화면

#0. 실행환경

  • next 13 프로젝트 생성
    • typescript 
    • tailwind (css)
    • zustand
    • dexie 

#1. 프로젝트 디렉토리 구조

 ...

∨ src/
  ∨ app/
    > ...
    > canvas/ :
      > _components:/ : "use client" 컴포넌트
      > _store/ : zustand store 
      > utils/ : 기타 함수들 (포맷변환 등 )
      > page.tsx : /canvas  페이지


#2. 이미지 파일 업로드

드래그 앤 드롭 및 클릭으로 이미지파일 업로드 기능 작업

// app/canvas/_components/canvas.tsx

"use client";

import {
  ChangeEvent,
  DragEvent,
  FunctionComponent,
  useEffect,
  useRef,
  useState,
} from "react";

export const Upload: FunctionComponent = () => {
  const ref = useRef<HTMLInputElement>(null);

  const [imageData, setImageData] = useState<File | undefined>();

  /**
   * 드래그앤드롭 핸들러
   * @param e
   * @returns
   */
  const dropHandler = (e: DragEvent<HTMLDivElement>) => {
    e.preventDefault();
    const dt = e.dataTransfer;
    const files = dt.files;

    if (files) {
      for (let i = 0; i < files.length; i++) {
        if (!files[i].type.match("image.*")) {
          return;
        }

        setImageData(files[i]);
      }
    }
  };

  /**
   * 파일 업로드 핸들러
   * @param e
   */
  const uploadHandler = (e: ChangeEvent<HTMLInputElement>) => {
    e.preventDefault();
    if (e.target.files?.length) {
      setImageData(e.target.files[0]);
    }
  };

  return (
    <div
      className="border-dashed border-gray-500 border-2 p-2 h-28 flex align-middle justify-center cursor-pointer hover:opacity-80"
      onDrop={dropHandler}
      onDragOver={(e: DragEvent<HTMLDivElement>) => {
        const target = e.target as HTMLDivElement;
        target.classList.add("opacity-80");

        e.preventDefault();
      }}
      onDragLeave={(e: DragEvent<HTMLDivElement>) => {
        const target = e.target as HTMLDivElement;
        target.classList.remove("opacity-80");
      }}
      onClick={() => {
        ref.current?.click();
      }}
    >
      <input
        ref={ref}
        type="file"
        hidden
        accept="image/*"
        multiple={false}
        onChange={uploadHandler}
      />
      클릭 또는 파일 드래그
    </div>
  );
};
  • onDrop: 파일 드롭 핸들러
  • onDragOver: 타깃 내 드래그 행위 체크 핸들러
    • 파일 드롭 핸들러 제어 및 디자인 컨트롤
  • onDragLeave: 아켓 내 드래그 아웃 체크 핸들러 
  • onClick : 드래그 앤 드롭 외 클릭 파일 가져오기 핸들러
    • <input type="file" hidden onChange={uploadHandler} />로 파일 가져오기 이벤트 로직 작업 후, div의 onClick 이벤트에서 해당 input.onChange() 호출

#3. 캔버스에 이미지 추가

#1. 에서 업로드된 이미지파일(imageData : File)을 읽어 메타정보 추출 및 캔버스에 해상도 조절하여 그리기

1. 캔버스 태그 선언

// app/canvas/_components/canvas.tsx

"use client";

import { FunctionComponent, useEffect, useRef, useState } from "react";

export const Canvas: FunctionComponent = () => {
  const ref = useRef<HTMLCanvasElement>(null);
 
  return (
    <div className="relative w-full h-full">
      <canvas
        id="canvas"
        width="1000"
        height="1000"
        className="absolute w-full h-full"
        ref={ref}
        tabIndex={1}
      />
    </div>
  );
};

 

2. 이미지 그리기

업로드한 이미지(File)를 읽어 Image 메서드를 이용하여 메타정보와 blob을 추출
추출된 정보들을 기반으로 캔버스의 drawImage() 함수를 통하여 캔버스 중심에 위치하도록 이미지를 그림

// app/canvas/_component/upload.tsx ~ useEffect()

const reader = new FileReader(); // 업로드된 파일 읽기

const canvas = document.querySelector("#canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");

/**
 * 캔버스 해상도 조절
 */
const dpr = window.devicePixelRatio;  // 사용 모니터의 단위 해상도 (px)
const rect = canvas.getBoundingClientRect();  // canvas 영역 정보 엇기
canvas.width = rect.width * dpr;	// 캔버스 영역 사이즈 x 모니터 단위 해상도
canvas.height = rect.height * dpr;  // 캔버스 영역 사이즈 x 모니터 단위 해상도
ctx?.scale(dpr, dpr);  // 캔버스 context 영역 스케일 조정
canvas.style.width = `${rect.width}px`;  // canvas 높이 고정 - tmp
canvas.style.height = `${rect.height}px`;  // canvas 높이 고정 - tmp
 
reader.addEventListener("load", (e) => { // 이미지파일 처리 프로세스
  // 파일 로드
  const img = new Image(); // 이미지 처리
  if (e.target?.result) {
    img.addEventListener("load", () => {
    const w = img.naturalWidth; // 실 사이즈
    const h = img.naturalHeight; // 실 사이즈

    /**
     * 비율 구하기
     */
    const wr = canvas.width / w;
    const hr = canvas.height / h;

    const scale = Math.min(wr, hr); // view 안에 담기도록

    /**
     * 캔버스 중간에 이미지 중심 올 수 있도록 버퍼값 구하기
     * d = canvas.center - view-image.center (실제 이미지 사이즈가 아니라, canvas에 그려질 비율조정된 이미지 사이즈)
     */
    const dx = canvas.width / 2 - (w * scale) / 2; // 캔버스 중심에 이미지 중심오도록 버퍼값
    const dy = canvas.height / 2 - (h * scale) / 2;

    ctx?.reset(); // 기존 이미지 지우기
    ctx?.drawImage(
      img,
      0,
      0,
      img.width,
      img.height,
      dx,
      dy,
      w * scale, // dw
      h * scale  // dy
    );
  });
  img.src = e.target.result as string;
  }
});
reader.readAsDataURL(imageData);
  • context.drawImage(imgSrc: CanvasImageSource, x: number, y: number, w?: number, h?: number, dx?: number, dy?: number, dw?: number, dh?: number)  
    • imgSrc: canvas Context에 추가할 이미지 소스 
    • x, y:  캔버스에 도식화할 이미지 영역의 시작포인트
    • w, h: 캔버스에 도식화 할 이미지 영역의 종료 포인트까지의 거리 (시작점으로부터의 거리)
      • 이미지 전체 그리기 : x, y. w, h -> 0, 0, img.width, img.height
      • 이미지의 특정영역(a1, b1, a2, b2)만 그리기:  x, y, w, h ->  a1, b1, a2-a1, b2-b1
    • dx, dy: 캔버스 콘텍스트에서 이미지 영역의 시작 포인트
    • dw, dh:  캔버스 콘테스트에서 이미지 영역의 종료 포인트까지의 거리 
  • 이미지 비율 구하기
    $$ scale_{size} = \frac {size_{canvas}}{size_{image}} $$
    • 넓이 비율 = 캔버스 너비 / 이미지 너비
    • 각 가로, 세로 비율을 구한 후 최솟값을 스케일값으로 지정 (이미지 가로, 세로 중 더 큰 쪽을 기준으로 스케일)
    • 각 이미지 사이즈에, 해당 스케일값을 곱하면 캔버스 콘텍스트 상에서의 dx, dy 값을 구할 수 있음
  • 이미지가 캔버스 중앙에 오도록 위치값 구하기
    $$ d_{size} = \frac {size_{canvas}}{2}   -  \frac {size_{image} * scale_{size}}{2}  $$
    • 캔버스 중심값 - 이미지 중심값
    • 캔버스 중심값에서  스케일 적용한 이미지 중심값을 빼면, 이미지의 시작위치를 계산할 수 있음
    • 종료위치 또한  이미지 사이즈에 스케일을 적용하면 구할 수 있음

후기

 익숙하게 다루지 않던 부분이라 단순한 알고리즘중심으로 작업했다. 계속 조금씩 작업하다 보면 로직들이 좀 더 깔끔해지지 않을까 싶은 생각이 들었지만, 기능들을 좀 더 활용해보기 위해 우선 진행하기로 했다.
 추 후, 캔버스 내 이미지상의 위치값(좌표값), 가이드라인, 확대보기 등 추가적으로 기술할 예정이다.