Canvas API를 이용한 노란우산공제 가입서비스 자필 서명 이미지 생성 모듈 구현기
2023년 04월 13일 한국신용데이터 기업 기술블로그에 게재한 내용입니다. 원본 확인하러 가기
안녕하세요, 저는 한국신용데이터의 뱅킹팀 웹 프론트엔드 개발 담당 이원국(Lee)입니다.
저희 캐시노트 뱅킹팀은 소상공인 금융 환경을 혁신적으로 개선하기 위해 끊임없이 고민하고 노력합니다. 이번에는 노란우산공제라는 소상공인 대상 공제 상품을 비대면으로 간편 가입할 수 있는 서비스를 런칭했습니다.
이번 프로젝트를 통해 많은 것을 공부하고 배워나갔습니다. 그중 서비스 가입 퍼널 중 전자 서명 입력 모듈이 특히 흥미로웠기에 기술 블로그를 통해 공유드립니다.
하나의 포스트로 정리하기엔 내용이 많아 두 편에 나누어 설명드리고자 합니다. 이번 파트에선 Canvas API를 사용하여 픽셀 데이터를 만들고, 분석, 편집하여 가공하는 과정을, 다음 파트에선 Broadcast Channel API를 사용해서 다른 웹뷰 컨텍스트끼리 데이터를 공유하는 과정을 설명하겠습니다.
아래 명시된 코드는 과정을 설명하기 위해 절차적인 단계로 재구성 되어 실제 구현된 코드와 다소 차이가 있습니다. copy and paste만으론 정상적인 작동을 보장할 수 없습니다.
무엇을 만들었나요?
캐시노트 앱을 사용하는 모바일 기기에서 고객이 쉽게 청약서와 청약 관련 제반 문서에 자필 서명을 할 수 있도록 돕는 디지털 자필 서명 모듈을 만들었습니다.
왜 만들었나요?
실제 협약 기관에서 공제 상품을 청약하는 과정을 디지털화 및 비대면, 간소화하여 더 많은 사장님들이 공제 혜택을 드리기 위해 만들어졌습니다.
저희 캐시노트에서는 유저가 입력한 청약 정보와, 홈택스 간편 인증 모듈을 통해 취합된 정보를 통해 전자 문서를 생성하고, 서명란에 자필 서명을 합성하여 PDF 및 바이너리 이미지 데이터로써 청약서 양식을 완성시켜 청약 과정에서의 고객 경험을 개선하고 있습니다.
어떻게 만들었나요?
JavaScript의 Canvas API를 사용하여 사용자가 서명을 그릴 수 있도록 하고, alpha값을 가지는 PNG 이미지를 추출하여 바이너리 데이터를 안전하게 난독화하여 서버에 올려보내어, 청약 서류에서 합성하는 프로세스로 구성되어 있습니다.
Canvas API를 통해 그림을 그리는 것은 간단했지만, 유효한 서명을 어떻게 판단할지, 엣지 케이스는 없을지, 어떻게 하면 서명 절차를 좀 더 feasible하게 만들 수 있는지가 고민이었습니다.
배울 수 있었던 것들
- Canvas API를 활용한 픽셀 이미지 분석, 편집 및 가공
- Uint8ClampedArray를 비롯한 Typed Array 자료형에 대한 이해
- Broadcast Channel API를 이용한 웹뷰 컨텍스트간 통신
브라우저에서 그림을 그리는 방법들 🎨
웹 프론트엔드의 무대는 브라우저입니다. 브라우저에서 그림을 그리는 방법은 아래와 같습니다.
SVG
SVG는 도형을 그리는 벡터 API입니다. 그려진 각 도형은 이벤트를 바인딩할 수 있는 객체로 존재합니다. 벡터 방식이기 때문에 래스터화 된 캔버스보다 확대했을때도 품질이 유지됩니다.
CSS
문서의 DOM 요소들을 스타일링합니다. HTML, JS와 함께 웹에서 빼먹고 이야기할 수 없는 필수 요소입니다. DOM 요소를 이용하거나 가상(pseudo) 요소를 시각적으로 꾸미는 역할을 합니다. 캔버스 영역에는 스타일을 바인딩할 수 있는 객체가 없기 때문에 Canvas API 내부적으로 CSS 스타일을 활용할 수는 없습니다.
DOM animation
CSS 또는 JavaScript를 사용해서 엘리먼트를 그리거나 이동하는 등 DOM Manipulation을 통해 애니메이션을 구현할 수 있습니다. 경우에 따라 Canvas로 그리는 것보다 더 부드러운 애니메이션을 얻을 수 있지만 이는 구현하는 브라우저마다 다릅니다.
Canvas API
Canvas는 Flash나 Java와 같은 플러그인을 사용하지 않고 웹 브라우저에서 개발자가 원하는 요소를 그릴 수 있도록 하기 위해 만들어졌습니다. 우리가 잘 아는 Apple에서 대시보드 위젯용으로 만들었고, 이후 모든 브라우저 벤더에서 채택하여 현재는 HTML5 공식 사양 중 하나가 되었습니다.
언제 Canvas API를 사용해야 할까요?
Canvas는 다른 방법보다 저수준으로 이미지를 제어하고, 메모리를 덜 차지하지만, 구현체를 만들기 위해 일반적으로 더 많은 코드를 작성하고 관리해야 합니다.
어도비 일러스트레이터 등을 사용한 벡터 기반의 기존 도형이 있는 경우 SVG를 사용합니다. 애니메이션을 적용하는 큰 정적 영역이 있거나, 3D 변형을 사용하는 경우 CSS 또는 DOM 애니메이션을 사용합니다.
차트, 그래프, 동적 다이어그램, 게임 등을 구현하기 위해선 캔버스는 좋은 선택이 될 수 있습니다. 또한 캔버스에서 벡터, 객체를 사용할 수 있도록 많은 라이브러리가 존재합니다. OpenGL을 사용한 WebGL도 캔버스를 활용하고 있습니다.
캔버스는 앞서 언급했듯이 이미 HTML 공식 사용이 된지 오래인지라, 브라우저 하위 호환성은 걱정하지 않아도 된다고 볼 수 있습니다.
같은 Canvas API에서도 사용하려는 스펙에 따라 호환성에 차이가 있을 수 있습니다.
Canvas 호환성 (출처: https://caniuse.com/canvas)
Canvas API를 사용한 이유
개발에 앞서 요구된 스펙과 해결해야 하는 문제를 정의하는 것이 가장 중요하다고 생각합니다. 서명 기능에 요구되는 스펙은 아래와 같이 정리할 수 있습니다.
- BottomSheet(또는 Drawer)에서 올라온 서명 패드에 유저가 자유롭게 자필 서명할 수 있어야 한다.
- 성능이 낮은 스마트폰에서도 충분히 작동 가능할 만큼 가벼운 성능이어야 한다.
- 유저가 식별 가능할 정도의 서명을 그렸는지 판단할 수 있어야 한다.
- 만들어진 서명의 여백을 잘라내고(cropping) 일관된 크기의 이미지 데이터로 리사이즈해서 서버에 보낼 수 있도록 한다.
위 내용을 다시 정리하면 아래와 같습니다.
- 인터랙션을 통해 그림 그리는 모듈
- 무거운 라이브러리 등에 의존하지 않는 library-agonstic
- 서명 유효성을 클라이언트 사이드에서 판단하기 위해 이미지를 분석할 수 있어야 한다.
- 크롭 및 리사이즈가 용이할 것
제가 아는 지식 내에선 Canvas API를 활 용하는 방법이 가장 쉽고 빠를 것으로 생각되었습니다.
서명 모듈 만들기 ⚙️
요구된 기능 명세
- 입력된 정보를 서버에서 PDF로 구성하고, 청약서의 서명란에 고객의 자필 서명이 들어가야 한다.
- 고객이 자필 서명을 하기 전 제공된 정보와 서비스 모듈을 통해 취합된 정보를 토대로 작성된 전자 청약 문서를 확인시켜야 한다.
- 전자 청약 문서의 정보가 이상이 없다면 클라이언트의 입력을 통해 서명 이미지가 합성된 전자 청약 문서 완성본을 확인시켜준다.
- 청약서가 정상적으로 작성되고, 처리될 수 있도록 유효한 서명을 정의하고, 정의된 기준에 부합하는 서명을 얻기 위해 유효성 검증이 필요했다.
- 전자 청약 문서는 별도의 URL이 아닌 암호화된 통신을 통해 직렬화된 binary 문자열 데이터를 Payload로부터 받아 화면에 렌더링하게 된다.
어떻게 만들었나?
- 클라이언트 단말의 터치를 통한 자필 서명 입력은 HTML 표준 스펙인 CANVAS API를 사용했다.
- CANVAS API에서 Pixel Manipulation을 위해 제공된 인터페이스를 사용하여 서명 이미 지의 배경 대비 전경 비율을 알 수 있었고, 이로써 유효한 서명인지 판별할 수 있었다.
- 청약서 이미지를 HTTP 통신을 통해 binary로 받아 화면에 렌더링하는 과정은 prefetch, in-memory caching을 사용하기 위해 react query를 사용했고, 바이너리 형식의 데이터를 주소값으로 참조할 수 있도록 Blob (Binary Large Object) 자료형과 관련된 인터페이스를 사용했다.
- 청약서 이미지를 새로운 웹뷰 컨텍스트를 띄워 렌더링하고, 백그라운드 컨텍스트와 메시지 또는 데이터를 직렬화(Serialization) 및 Parsing 과정 없이 처리하기 위해 Broadcast Channel API를 사용했다.
서명 이미지 처리 프로세스와 사용된 Canvas API 스펙
1. 초기화
1-1. 컴포넌트 위계 및 Prop 설정
- canvas element와 참조할 값과 메소드를 활용할 수 있는 컨텍스트(getContext 반환 객체)를 heap에 저장합니다.
- 캔버스 요소의 기본 속성과 설정 값을 초기화합니다.
interface Props {
fullyOpened: boolean;
renderId: string;
placeholder?: string;
opened: boolean;
onSubmit: (encodedSignature: string) => void;
}
export default function SignPad({
placeholder,
renderId,
fullyOpened,
opened,
onSubmit,
}: Props) {
const heap = React.useRef(new Map());
const [isDirty, setIsDirty] = React.useState(false);
// ...
}
Props
placeholder
- 서명 패드가 그려지기 전까지 서명 영역에 나타날 안내 문구입니다.renderId
- 각 서명 패드 컴포넌트의 키 값으로 사용되는 고유한 UUID 입니다.fullyOpened
- 컨테이너가 화면으로 나타나는 트랜지션이 끝나는 타이밍을 알려주는 플래그입니다.opened
- 서명 패드가 화면에 나타난 상태인지 여부를 나타냅니다.onSubmit
- 유저가 서명을 마치고 Submit 이벤트를 발생시켰을때 호출될 핸들러입니다.
Context
heap
- useRef를 사용하여 서명 패드의 Canvas DOM 요소와 해당 요소의 컨텍스트(ctx)를 저장하기 위해 사용합니다. Canvas 요소를 useRef로 바로 참조하지 않고 Map 컬렉션을 사용하는 이유는 첫째, 캔버스 요소와 캔버스 요소의 컨텍스트를 같은 위계의 프로퍼티로 관리하고 참조하기 위함이며, 둘째, 서명이 리셋될 때 오염되는 것을 막기 위해 명시적으로 clear 메소드를 사용하기 위함입니다. get, set, has, clear 등 Map 자료형을 다루기 위한 메서드가 더 직관적이어 보여서 사용한 점도 있습니다.isDirty
- 캔버스에 사용자의 입력이 있었는지 여부를 나타냅니다. 단 한번의 터치라도 인식되면 false가 됩니다.setIsDirty
- isDirty의 값을 변화시키는 세터입니다.
{
// ...
React.useEffect(() => {
// (1)
if (!opened && isDirty) {
setIsDirty(false);
}
}, [isDirty, opened]);
React.useEffect(() => {
// (2)
heap.current.clear();
const dom = document.getElementById(renderId) as HTMLCanvasElement;
heap.current.set("node", dom);
heap.current.set("ctx", dom.getContext("2d"));
}, [placeholder, renderId]);
// ...
}
- 서명 모듈이 닫히고 다시 열릴때마다 유저의 입력 여부를 초기화합니다. (1)
- renderId가 바뀌어 새로운 컴포넌트로 렌더링 될 때 heap 객체를 초기화합니다. (2)
heap.current 속 Map 객체의 프로퍼티를 지워줍니다.
- 새로운 UUID를 부여받아 replace된 Canvas DOM 요소를 찾아 dom 변수에 할당합니다.
- Map 객체의 node 프로퍼티에 dom을 설정합니다.
- Map 객체의 ctx 프로퍼티에 생성하여 등록된 Canvas 요소의 Context를 저장합니다.
1-2. 캔버스 노드 및 컨텍스트
{
// ...
React.useEffect(() => {
// (1)
const node = heap.current.get("node") as HTMLCanvasElement;
const ctx = heap.current.get("ctx") as CanvasRenderingContext2D;
if (!node || !fullyOpened) return;
// (2)
const standardWidth = window.innerWidth - 40;
const standardHeight = 180;
const scale = 2;
// (3)
node.style.width = `${standardWidth}px`;
node.style.height = `${standardHeight}px`;
node.width = standardWidth * scale;
node.height = standardHeight * scale;
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
ctx.lineWidth = 4;
ctx.lineCap = "round";
ctx.lineJoin = "round";
// (4)
if (!isDirty) {
ctx.font = "36px Pretendard Arial";
ctx.fillStyle = "#3F4A56";
ctx.fillText(
placeholder,
(node.width - node.width / 2) / 2,
node.height / 2
);
}
// (5)
node.addEventListener("touchstart", () => {
if (!isDirty) {
setIsDirty(true);
}
});
node.addEventListener("touchend", () => {
ctx.stroke();
ctx.beginPath();
});
node.addEventListener("touchmove", draw(scale));
}, [draw, fullyOpened, isDirty, placeholder, renderId]);
// ...
}
-
heap 객체에 등록된 Canvas 요소와 해당 요소의 Canvas Context를 참조합니다.
-
캔버스의 너비와 높이를 지정하고, 작은 화면에서 높은 해상도의 이미지를 산출하기 위해
scale
은 2배수로 설정합니다. -
캔버스 요소의 기본적인 스타일을 정의합니다. 캔버스는 두 방식의 사이즈 속성을 가집니다. 첫째는 캔버스의 픽셀 크기를 결정하는 Attribute로써 HTMLCanvasElement.width/height를 사용하며 HTMLElement.style.width/height는 css 스타일로 나타낸 디스플레이 크기입니다. 이 속성들을 이용하면 작은 화면에서도 높은 해상도의 이미지를 얻어낼 수 있습니다.
node.style.with
,node.style.height
에 앞서 지정한 너비를 할당합니다.node.width
,node.height
는 캔버스에 표시되는 픽셀 단위로 디스플레이 크기의 2배수로 지정하여 더 많은 픽셀을 그려 높은 해상도의 서명 이미지를 얻도록 합니다.
-
유저가 서명 모듈에 터치를 시작할 때 까지 이곳에 서명을 해달라는 Placeholder의 스타일을 정의합니다. 유저가 서명 영역에 터치를 시작하면 Placeholder는 사라집니다.
-
캔버스 요소에 터치 이벤트를 바인딩합니다. 위 코드엔 표시되지 않았지만 바인딩 된 이벤트들은 모듈이 사용되지 않는 시점에 clean-up 함수 호출을 통해 바인딩을 해제합니다.
touchstart
- 유저가 서명 영역에 터치를 시작하면 isDirty 플래그를true
로 변환합니다.touchend
- 유저의 터치 이벤트가 끝나는 좌표까지 선을 그려주고CanvasRenderingContext2D.beginPath()
메소드로 서브 패스(sub-path)를 초기화해줍니다.touchmove
- 실제 유저의 터치 경로가 선으로 그려지는 함수draw()
를 호출하는 부분입니다.draw()
함수는 픽셀 단위 대비 디스플레이 단위 비례를 나타내는 상수scale
을 인자로 받고 있습니다.
2. 입력
2-1. 서명 그리기
유저의 터치 이벤트에 바인딩 될 draw()
함수와 캔버스를 비워주는 clear()
함수로 입력을 제어합니다.
const draw = React.useCallback(
(scale: number) => {
return ({ targetTouches }: TouchEvent) => {
const ctx = heap.current.get("ctx") as CanvasRenderingContext2D;
const bound = (
heap.current.get("node") as HTMLCanvasElement
).getBoundingClientRect();
ctx.lineTo(
(targetTouches[0].clientX - bound.left) * scale,
(targetTouches[0].clientY - bound.top) * scale
);
ctx.stroke();
};
},
[renderId]
);
renderId
가 변할 때마다 컨텍스트가 바뀌기 때문에 renderId
를 dependency로 하는 useCallback 훅을 사용했습니다. 계산이 많은 무거운 로직이 아닌 경우 Memorization이 오히려 Overhead라는 의견이 분분하지만 이 주제는 다음에 다뤄보겠습니다.
draw()
함수의 역할을 간단히 정리하면 아래와 같습니다.
touchmove
이벤트로부터 넘겨받은 TouchEvent 이벤트 객체로부터 인터랙션을 통해 입력된 좌표를 구합니다. 저는TouchEvent.targetTouches
라는 내장 프로퍼티를 참조했습니다. 물론 픽셀 스케일로 보정값을 산출해주어야 합니다.ctx.lineTo()
내장 함수에 입력받은 x, y축 상대 좌표값을 전달하여 그려지는 서명 라인 sub-path의 마지막 포인트를 지정합니다.ctx.stroke()
내장 함수 를 사용하여 2단계에서 지정된 좌표까지 선을 그려줍니다.touchmove
이벤트가 발생될 때마다 위 단계가 계속해서 반복됩니다. (touchmove
이벤트는 100ms당 약 5~6번 디스패칭 됩니다 - 후술)
2-2. 왜 쓰로틀링(Throttling)을 사용하지 않았나요?
보통 터치 스크린은 60~120Hz의 주사율로 터치 입력을 인식합니다 (하지만 디스플레이 출력은 대부분 60Hz입니다. 출처). wheel
, mousewheel
, touchmove
, pointermove
, mousemove
와 같은 지속적인 이벤트 디스패칭은 보통 쓰로틀링이라는 빈도수를 임의로 제한하는 방법으로 성능 부하를 완화합니다.
쓰로틀링을 사용하지 않았을때의 성능 지표
Touch Move Event Dispatched
와 Paint
의 지표를 살펴보면 약 100ms당 약 5번의 리페인트와 터치 이벤트가 트리거 되었음을 볼 수 있습니다.
이에 반해 약 40ms의 레이턴시를 설정하여 쓰로틀링을 걸어본 결과 성능 지표는 아래와 같습니다.
쓰로틀링을 사용했을 때의 성능 지표 (latency 40ms)
터치 이벤트의 발생 빈도수는 다를것이 없지만 레이아웃, 렌더링 관련 지표(Paint, Composite)가 다소 완화되어 보입니다.
쓰로틀링 사용 여부에 따른 이미지 품질
유의미한 성능 개선 효과라고 하기 어려운 결과를 얻은 반면, 이미지의 곡선 부분의 품질은 급격히 떨어집니다. 그럼에도 브라우저 메인 스레드의 평균 CPU Usage는 사용 여부와 관계 없이 7–8%대로 별 차이가 없었습니다. (Webkit 기준 amd64 iOS Simulator 환경)
모바일 웹뷰에서 구현되는 만큼 디스플레이 폼팩터와 서명 모듈의 입력부 영역도 작아서 쓰로틀링을 적용하는 것보단 더 많은 이벤트 입력으로 서명을 비교적 부드럽고 정교하게 그려내는 편이 더 낫다는 판단으로 쓰로틀링을 사용하지 않았습니다.
requestAnimationFrame 함수 사용 여부에 따른 FPS 지표
디스플레이 주사율보다 잦은 이벤트 발생으로 인한 불필요한 콜스택을 줄이기 위해 requestAnimationFrame()
을 사용해 보았습니다. (참조) 하지만 FPS(Frame Per Second)의 개선은 없거나 미미한 정도로 보였습니다.
단, 자바스크립트 이벤트 루프에서 앞서 언급한 함수는 chromium 브라우저와 webkit 브라우저가 작동 시점이 달라서 확증적으로 차이가 없다고 말씀드리긴 이릅니다.
관심 있으신 분들은 JSConf 세션 영상을 참고하세요.
Web Browser Event Loop (JSConf)
2-3. 서명 지우기
const clear = () => {
if (!isDirty) return;
const node = heap.current.get("node") as HTMLCanvasElement;
const ctx = heap.current.get("ctx") as CanvasRenderingContext2D;
ctx.clearRect(0, 0, node.width, node.height);
setIsDirty(false);
};
ctx.clearRect()
내장 메소드를 사용하면 캔버스 내부에 그려진 픽셀들을 정리할 수 있습니다.
3. 서명 유효성 검증
유효한 서명이란 무엇일까요? 사람의 생김새가 모두 다르듯 서명도 모두 제각각입니다. 그렇다면 반대로 유효하지 않은 서명은 무엇을 기준으로 판단해야 할까요? 기능 명세서엔 명시되지 않았지만 아래의 두 기준으로 정리해 보았습니다.
- 너무 적게(또는 작게) 그려서 충분히 서명으로 인식되지 않은 이미지
- 가장자리에 치우쳐 그려저서 청약서에 합성했을때 서명란에서 벗어나는 이미지
유의미한 서명으로 인지하기 어려운 부적합한 입력의 예
다른 이유도 아니고 서명이 제대로 그려지지 않아 서류 접수 후 청약이 거절되는 일은 막아야 합니다. 그렇다면 유저가 유효하지 않은 서명을 그렸을때 유저 에게 안내해주어야 합니다.
3-1. 어떻게 서명이 적게 그려졌는지 알 수 있을까?
서명 이미지의 크기로 판단하면 작은 점을 사방팔방 찍은 이미지의 가장자리가 크게 잡혀 유효하지 않습니다.
다행히 서명이 단색의 선으로 그려진다는 점에 착안하여, 그려진 검은 픽셀과 그려지지 않은 투명한 알파 채널 투명도(alpha channel transparency)가 0인 픽셀의 비례를 구하여 그려지지 않은 픽셀이 몇 퍼센트 이하일 경우를 걸러낼 수 있었습니다.
Canvas API에서는 정적으로 픽셀을 분석하고 조작(manipulation)할 수 있도록 getImageData()
메서드를 제공합니다.
// Syntax
getImageData(sx, sy, sw, sh);
getImageData(sx, sy, sw, sh, settings);
sx
- 추출될 이미지의 좌상단 x축 좌표값sy
- 추출될 이미지의 좌상단 y축 좌표값sw
- 추출될 이미지의 너비로써 양수일 경우 오른쪽 방향으로, 음수일 경우 왼쪽 방향으로 영역을 잡습니다.sh
- 위와 마찬가지로 양수일 경우 아래, 음수일 경우 위로 높이 영역을 잡습니다.
서명이 그려진 캔버스의 컨텍스트(ctx)에서 위 메소드를 사용하면 ImageData
라는 객체를 반환하는데 이 객체의 프로퍼티인 ImageData.data
라는 Uint8ClampedArray
자료형의 객체를 사용합니다.
Uint8ClampedArray란 형식화 배열(TypedArray)의 한 종류로써 0–255로 고정된 8비트 부호(sign) 없는 정수의 배열입니다.
콘솔에 찍어보면 0부터 255까지의 값이 이미지 픽셀 수의 4배수로 배열 가득 들어차있습니다.
Uint8ClampedArray 형식화 배열 속 RGBA 값의 연속
여기서 흥미로운 것은 이 값들이 연속된 RGBA(red, green, blue, alpha) 순서로 이루어져있다는 점입니다. 분석할 이미지의 좌측 상단에서부터 우측 하단까지 횡 방향으로 픽셀 단위 한 줄씩 1차원 배열로 픽셀의 RGBA 값을 나타냅니다.
A Uint8ClampedArray representing a one-dimensional array containing the data in the RGBA order, with integer values between 0 and 255 (inclusive). The order goes by rows from the top-left pixel to the bottom-right.
따라서 네 개의 인덱스를 하나의 픽셀로 보고, RGB의 alpha 값이 불투명에 가까운 픽셀은 서명(전경)으로, alpha값이 0에 가까운 투명한 픽셀은 바탕(배경)으로 판독하도록 만들어봅니다.
픽셀을 분석하고 리사이징하는 변수와 함수들은 같은 네임스페이스를 공유하고 인스턴스 단위로 묶어놓도록 class를 사용했습니다. 또한 전경 대비 배경 비율을 가독성 좋게 참조하기 위해 메소드 대신 getter로 만들었습니다.
3-2. Uint8ClampedArray 자료형의 RGBA 배열로 그려진 서명과 배경의 비율 구하기
get entityAndBackgroundRatio() {
let entity = 0; // (1)
let background = 0; // (2)
for (let i = 0; i < this.#imageData.length; i += 4) { // (3)
if (this.#imageData[i + 3] > this.entityThreshold) {
entity += 1;
} else {
background += 1;
}
}
return {
entity,
background,
ratio: (entity / background) * 100, // (4)
};
}
entity
변수에는 유효한 서명으로 판독되는 픽셀의 수를 나타냅니다.background는
전경에 대비한 배경 픽셀 수를 나타냅니다.- 이미지 데이터의 전체 길이를 4배수씩 인덱스를 높이며 픽셀을 정적 분석합니다.
entityThreshold
는 클래스 생성자 함수에서 초기화 할때 지정했던 멤버이며, 알파값에 따라 전경 또는 배경으로 나뉘는 임계치를 나타냅니다. - 반환 값으로 전경 대 배경의 백분율을 나타내는데, 저희가 생각한 유효한 서명의 기준은 3% 이상의 면적이 불투명한 픽셀일 경우로 정했습니다.
3-3. Pixel Processing Visualization
이해를 돕기 위해 imageData.data 프로퍼티에 담긴 픽셀 데이터를 인덱스 순서로 파싱하여 글자를 이루는 픽셀의 수를 찾아내는 과정을 시각화 하였습니다. (아래 링크)
https://github.com/wonkooklee/pixel-manipulation-with-canvas
실제 프로세스는 불과 몇 ms만에 완료되지만 과정의 시각화를 위해 의도적으로 지연을 발생시켰습니다. 자세한 코드는 위 repository의 코드를 참조하세요.
imageData의 RGBA 배열을 순서대로 변경하는 모습
서명 이미지는 투명도(Alpha Channel)를 가지는 PNG 확장자를 사용합니다. 따라서 불투명한 픽셀은 전경(서 명), 투명한 픽셀은 배경으로 분리될 수 있습니다.
Foreground
- 파싱된 픽셀 중 불투명한 픽셀(전경)의 수를 나타냅니다.Background
- 파싱된 픽셀 중 투명한 픽셀(배경)의 수를 나타냅니다.Ratio
- 전경 대비 배경의 백분율을 구합니다. 이 수치를 통해 유저가 충분히 서명을 입력했는지 여부를 판단할 수 있습니다.Threshold
- 투명/불투명을 판단하기 위한 alpha channel의 투명도 기준 수치를 정합니다.
3-4. 서명 이미지 가장자리 좌표 구하기
어떻게 이미지를 재정렬하고 서명의 비례에 맞춰 리사이징 할 수 있을까?
결국 우리가 하고싶었던 것은 이미지 크롭 후 리사이징입니다. 서명 이미지를 크롭하려면 어디까지 이미지를 잘라내야 하는지 상하좌우 각 가장자리의 불투명한 픽셀 좌표를 구해야합니다.
제가 생각했던 잘라낼 이미지 가장자리 구하는 방법은 아래와 같습니다.
- 앞서 얻은
imageData.data
는 이미지 픽셀 가로 한 줄씩 위에서 아래로, 좌에서 우로 RGBA값을 표현한다. - 가로 한 줄 단위로 배열을 자른다. (
imageData.data
/width
) - 위에서 만든 각 픽셀 줄에서 가장 낮은 index의 불투명 픽셀은 이미지의 왼쪽 가장자리고, 가장 높은 index의 불투명 픽셀은 이미지의 오른쪽 가장자리다. (서명 이미지 좌우측 좌표 및 너비 확보)
- 위에서 아래로, 좌에서 우의 순서로 불투명 픽셀의 index를 담은 배열 vertical에서 0번째 index의 좌표는 이미지의 위쪽 가장자리, 마지막 index는 아래쪽 가장자리다. (서명 이미지 상하 좌표 및 높이 확보)
위 논리를 코드로 바꾸어 아래의 함수를 만들었습니다.
getCroppingCoordinates() {
const vertical = []; // (1)
const horizontal = []; // (2)
for (let i = 0; i < this.#imageData.length; i += 4) { // (3)
if (this.#imageData[i + 3] > this.entityThreshold) {
vertical.push(Math.floor(Number(i / 4 / this.width)));
horizontal.push((i / 4) % this.width);
}
}
if (!vertical.length || !horizontal.length) {
return null;
}
return { // (4)
top: vertical.shift() || 0,
bottom: vertical.pop() || 0,
left: Math.min(...horizontal),
right: Math.max(...horizontal),
};
}
그리고 위 과정 또한 설명을 돕기 위해 간단한 예시를 만들었습니다.
Alpha Channel 값을 기준으로 이미지 크롭 라인 만들기
Top
,Right
,Bottom
,Left
- 파싱 과정에서 얻어진 이미지의 가장자리 위치값을 나타냅니다. Top, Bottom과 같은 수직적 위치 값은 이미지 최상단으로부터 떨어진 픽셀을 나타내며, Left, Right와 같은 수평적 위치 값은 이미지의 좌측 가장자리로부터 떨어진 픽셀을 나타냅니다. 우리는 이 값들을 통해 크롭되는 이미지의 너비와 높이 값도 알 수 있습니다.
Width = Right - Left(px);
Height = Bottom - Top(px);
Offset
- 크롭될 이미지의 마진을 설정할 수 있습니다. 위 예시처럼 글자로부터 5px씩 띄워서 크롭되어도 유실되는 픽셀이 없도록 안전 마진을 줄 수 있습니다.
3-5. 이미지 자르기
위 단계를 통해 이미지가 크롭될 위치와 너비, 높이가 정해졌습니다. 이제 이미지를 잘라봅시다.
getCroppedImage() {
const coords = this.getCroppingCoordinates();
if (!coords) return null;
const { top, bottom, left, right } = coords;
if (
right - left < this.validMinimumImageLength ||
bottom - top < this.validMinimumImageLength ||
this.entityAndBackgroundRatio?.ratio < this.validMinimumPixelRate
) {
throw new PixelProcessorError({
errorCode: 'notEnoughSize',
message:
'The given image is too small to recognize as a valid signature.',
});
}
const virtualCanvasElement = document.createElement('canvas');
virtualCanvasElement.width = right - left + this.offset;
virtualCanvasElement.height = bottom - top + this.offset;
const virtualCtx = virtualCanvasElement.getContext(
'2d',
this.renderingOptions,
) as CanvasRenderingContext2D;
this.imageRatio = (bottom - top) / (right - left);
const extracted = this.#canvasContext!.getImageData(
left - (this.offset ? this.offset / 2 : 0),
top - (this.offset ? this.offset / 2 : 0),
right - left + this.offset,
bottom - top + this.offset,
);
virtualCtx.putImageData(extracted, 0, 0);
const dataUrl = virtualCanvasElement.toDataURL('image/png');
virtualCanvasElement.remove();
return dataUrl;
}
-
앞서 설명드린
getCroppingCoordinates()
메소드로 크롭될 영역의 가장자리 위치를 구합니다. -
유저의 입력 조건이 충분하지 않은 경우 early return 합니다.
PixelProcessorError
라는 기존 에러 객체를 extend한 커스텀 에러 객체가 던져집니다.
- 크롭될 이미지의 폭 또는 높이가 기준(
validMinimumImageLength
)보다 작은 경우 - 서명 이미지의 배경 대비 전경 비율이 기준(
validMinimumPixelRate
)보다 적은 경우
-
로컬 스코프에
<canvas>
요소 참조를 생성합니다. DOM Tree에 요소를 추가 및 렌더링 하지 않고도 이미지를 추출하기 위해 Canvas 요소의 특성을 활용할 수 있습니다. 추출될 이미지의 크기에 맞게 가상 캔버스의 너비와 높이를 설정합니다. -
이미지의 종횡비를 구합니다. 이 값은 이미지 리사이징 할때 장축의 길이를 균일하게 맞추기 위해 사용됩니다.
-
기존 서명 이미지로부터 크롭될 부분의
imageData
버퍼를 추출합니다. 전달된 인자의 순서는 추출될 이미지의 좌측 가장자리 위치(x), 상단 가장자리 위치(y), 너비(w), 높이(h) 순입니다. -
(5) 단계에서 추출된 이미지 버퍼를 가상 캔버스 요소에 삽입합니다.
-
이미지 데이터 URL을 추출합니다.
toDataURL()
메소드는 캔버스 요소의 이미지 데이터를 전달된 타입의 이미지 포맷으로 export 됩니다. mime type을image/png
로 지정하면 아래와 같은 형식의 문자열 데이터가(base64) 반환됩니다.
toDataURL() 메소드가 반환한 png 이미지의 base64 인코딩 문자열
추출에 사용된 캔버스 요소는 remove()
메소드를 사용해서 detach합니다.
4. 이미지 리사이징
이미지의 장축을 지정된 크기로 리사이징하여 서버에 균일한 크기의 이미지만 전송될 수 있도록 합니다.
주요 코드는 앞서 설명드린 절차와 비슷하여 간단히 설명하겠습니다.
resizeImage(lengthLimit: number) {
if (
lengthLimit * this.imageRatio < 1 ||
lengthLimit / this.imageRatio < 1
) {
throw new PixelProcessorError({
errorCode: 'wrongArgumentPassed',
message:
'The given length limit is too small. The shorter length must be greater than 1.',
});
}
return (callback: (resizedBase64Data: string) => void) => {
const dataUrl = this.getCroppedImage();
if (!dataUrl) return;
const virtualCanvasElement = document.createElement('canvas');
const virtualCtx = virtualCanvasElement.getContext(
'2d',
this.renderingOptions,
) as CanvasRenderingContext2D;
const { width, height } = (function calcProportion(that) {
if (that.imageRatio > 1) {
return {
width: lengthLimit / that.imageRatio,
height: lengthLimit,
};
}
return {
width: lengthLimit,
height: lengthLimit * that.imageRatio,
};
})(this);
virtualCanvasElement.width = width;
virtualCanvasElement.height = height;
const img = new Image();
img.src = dataUrl;
img.crossOrigin = 'anonymous';
const loadEventHandler = () => {
virtualCtx.drawImage(img, 0, 0, width, height);
const dataExceptMimeType = virtualCanvasElement
.toDataURL('image/png')
.split('base64,')[1];
void callback(dataExceptMimeType);
virtualCanvasElement.remove();
img.remove();
};
img.addEventListener('load', loadEventHandler);
};
}
getCroppedImage()
메소드가 반환하는 cropped-imageData(base64)를 리사이징 될 크기의 새로운 가상 캔버스에 맵핑합니다.putImageData
가 아닌 base64 형식의 이미지 데 이터를 가지고 있기 때문에drawImage
메소드를 사용합니다.- 이미지 로드 이벤트가 비동기이기 때문에 결과값을 전달받는 콜백을 반환하여
caller
에서 제어하도록 합니다.
Consumption
const node = heap.current.get("node") as HTMLCanvasElement;
const controller = new MySignature(node, 60, 0.8, 60, 10);
const handleResult = controller.resizeImage(280);
handleResult(onSubmit);
마땅한 이름이 생각나지 않아 모듈에 MySignature
라는 이름을 붙여보았습니다.
초기화 함수는 아래의 순서로 인자를 받습니다.
canvasElement
- HTML Canvas 노드entityThreshold
- 픽셀 파싱할 때 서명 이미지의 투명도 판정 기준값validMinimumPixelRate
- 입력되어야 하는 배경 대비 전경 비율의 최소값validMinimumImageLength
- 입력되어야 하는 서명의 최소 크기 (가로와 높이 동일)offset
- 이미지 크롭시 적용될 테두리 마진값
여담: CPU Canvas vs. GPU Canvas
캔버스를 사용할때 브라우저는 휴리스틱(Heuristics)에 따라 메인 메모리에 캔버스 데이터를 저장하며 CPU를 활용하여 렌더 링할 수도 있고, GPU를 사용하여 캔버스를 구성하고 작동시킬 수도 있습니다. 편의상 전자는 CPU 캔버스, 후자는 GPU 캔버스라 칭하겠습니다.
GPU 캔버스는 하드웨어 가속을 사용하여 CPU 캔버스에 비해 더 나은 퍼포먼스를 보인다고 합니다. 하지만 언제나 더 나은 성능을 기대한다고 보장할 순 없으며, 상황에 따라 CPU 캔버스가 유리한 경우도 있습니다. 바로 이번 서명 모듈과 같이 빈번하게 getImageData()
, putImageData()
를 호출해서 픽셀 데이터를 조작해야 하는 경우가 그렇습니다.
이 경우 GPU 캔버스를 사용하는 경우, 캔버스 버퍼(buffer)를 연산하기 위해 다시 CPU로 데이터를 readback해야 하는데, 이러한 상황이 빈번하게 발생되는 경우 CPU에서 전적으로 캔버스를 제어하는 것이 성능상 비용이 더 적다고 합니다.
하지만 어느 하드웨어 리소스를 사용할지는 브라우저 휴리스틱을 통해 결정되기 때문에 적극적으로 사용하기는 어렵다고 합니다. Figma에서 캔버스 대신 WebGL을 사용한 이유도 캔버스가 GPU 하드웨어 가속(GPU hardware acceleration)을 통한 성능을 보장하기 어렵기 때문이라고 하네요.
HTMLCanvasElement.getContext("2d", { willReadFrequently: true });
아무튼 이번 사례와 같이 빈번한 데이터의 read/write가 필요한 경우 캔버스 노드의 최초 getContext()
메소드 호출시 willReadFrequently
옵션을 true
로 설정해서 메모리를 최적화 할 수 있다고 합니다.
This will force the use of a software (instead of hardware accelerated) 2D canvas and can save memory when calling getImageData() frequently.
References
- https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Pixel_manipulation_with_canvas
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8ClampedArray
- https://joshondesign.com/p/books/canvasdeepdive/title.html
- https://www.middle-engine.com/blog/posts/2020/08/21/cpu-versus-gpu-with-the-canvas-web-api
- https://groups.google.com/a/chromium.org/g/blink-dev/c/NPSQdiXSK4w/m/jgzIaJPJxh8J
- https://stackoverflow.com/questions/74101155/chrome-warning-willreadfrequently-attribute-set-to-true
- https://bucephalus.org/text/CanvasHandbook/CanvasHandbook.html