LINE Web Timeline 이미지 얼굴 인식 기능 적용

들어가며

안녕하세요. 저희는 LINE UIT 조직에서 프런트엔드 업무를 담당하고 있는 김은재, 이상원입니다. 이번 글에서는 저희가 LINE Web Timeline에서 얼굴 인식을 통해 전달받은 좌표를 CSS로 표현하기 위해 고민했던 내용을 공유하려고 합니다. 

프런트엔드 개발을 진행하다 보면 CSS를 사용하면서도 막상 그 스펙을 깊이 이해하지 못하고 사용하게 되는 경우가 종종 있습니다. 이번 작업을 담당하며 그동안 공부가 부족했다는 것을 통감했는데요. CSS를 이해하면 할수록 브라우저에서 CSS를 구현하기 위해 얼마나 많은 고민이 필요한지 깊이 공감하게 되었습니다.

글은 다음과 같은 순서로 진행합니다.

 

배경 설명

LINE Web Timeline에서 첨부하는 이미지에 얼굴이 포함되어 있는 경우에 대한 스펙이 아래와 같이 변경되었습니다. 이번 글에서는 변경된 스펙을 적용하는 과정을 정리해 보려고 합니다. 

  • 변경된 스펙 요약
    • 이미지는 정해진 공간 내에서 이미지의 비율을 해치지 않는 최대 크기로 표현됩니다.
    • 정해진 공간 내에서 이미지가 표현될 때 빈 공간을 노출하지 않습니다.
    • 이미지에 얼굴이 인식될 경우 정해진 공간의 중심에 얼굴 좌표의 중심을 맞춰 표현합니다.
      • 얼굴이 다수로 인식될 경우 다수의 중심을 정해진 공간의 중심에 표현합니다.

기존에도 얼굴 인식 기능을 제공하고는 있었지만, 여러 명의 얼굴이 포함되어 있을 때 제대로 동작하지 않거나 얼굴 인식 기능이 부적절한 좌표로 표현되는 등의 문제가 있어서 서비스 전체적으로 개선해야 하는 상황이었습니다. LINE 앱에 먼저 변경된 스펙이 적용됐고, Web Timeline에도 적용하게 되었습니다.

Web Timeline에서는 이미지가 다양한 형태로 표현됩니다. 이미지의 가로세로 비율에 따라 정방형이나 직사각형 등의 형태로 이미지를 제공하고, 그리드 혹은 슬라이드 등의 조건에 따라서 이미지를 포함하는 박스의 형태가 달라집니다. 이번 글은 정해진 박스 형태 내에서 이미지의 특정 좌표가 우선적으로 노출될 수 있도록 표현하는 것에 중점을 두고 설명하겠습니다. 

 

구현 과정

@이상원
“스펙을 구현하기 위해 가장 먼저 작업한 내용은 이미지와 함께 전달된 좌표를 정제해 중심을 구하는 일이었습니다. 중심 좌표를 구하는 과정을 간략하게 정리해 보았습니다.”

 

중심 좌표 산출

얼굴 좌표는 사진이나 비디오 처리를 개발하는 OBS 팀에서 이미지가 저장될 때 이미지를 분석하여 x1, y1, x2, y2를 배열 형태로 전달해 주고 있습니다.

face coordinates 예시

faceCoordinates: [
    {
        "x1": 994,
        "y1": 255,
        "x2": 1102,
        "y2": 362
    },
    {
        "x1": 157,
        "y1": 235,
        "x2": 264,
        "y2": 343
    }
]

x1, y1, x2, y2는 이미지에서 표현할 위치의 정확한 좌표를 나타내고 있습니다. 이 좌표의 중앙을 구하는 방법은 간단했습니다.

const unionFaceCoodinates = faceCoordinates.reduce((accumulator, currentValue) => {
    if (!accumulator) return currentValue;
    return {
        x1: Math.min(accumulator.x1, currentValue.x1),
        y1: Math.min(accumulator.y1, currentValue.y1),
        x2: Math.max(accumulator.x2, currentValue.x2),
        y2: Math.max(accumulator.y2, currentValue.y2),
    }
})
const getPosition = (coodinate) => {
    return {
        x: (coodinate.x1 + coodinate.x2) / 2,
        y: (coodinate.y1 + coodinate.y2) / 2,
    }
}
const position = getPosition(unionFaceCoodinates);

unionFaceCoodinates는 faceCoordinates 배열을 순회하면서 x1 / y1의 최솟값과 x2 / y2의 최댓값을 구합니다. 이 과정을 통해 배열로 전달된 각 faceCoordinate 값의 전체 크기를 구할 수 있습니다.

getPosition은 unionFaceCoodinates로 구해진 값의 중앙 좌표를 찾는 역할을 합니다.

중앙으로 표현될 이미지의 포지션을 구하고 나니 어떤 방식으로 구현할지 UI-DEV 팀과 논의해 볼 수 있게 되었습니다.

@김은재
“CSS로 이미지를 좌표에 맞춰 컨테이너 중심에 표현하기 위해서는 두 가지 속성을 정의해야 했습니다.
1. 이미지 크기에 대한 정의
2. 전달받은 좌표를 컨테이너 중앙에 표현하기 위한 이미지 위치에 대한 정의
두 가지 정의를 통해 이미지가 어떤 크기로 노출되고, 위치 값을 어떤 형태로 받아야 하는지 논의할 수 있겠다고 생각해 먼저 이미지 사이즈 표현 방식부터 정리했습니다.”

 

CSS image-size 표현 방식 정의

스펙 1과 2를 충족시키기 위해 이미지의 크기를 정의하는 CSS cover 1값을 사용하기로 결정했습니다. cover는 background-size나 object-fit 속성에서 사용되는 크기에 대한 값입니다. 

 

중심 좌표를 CSS로 구현

 

이미지를 9분할해 position 클래스 분기 처리

cover 값을 사용하면서 이미지가 정해진 공간 내에서 최대 크기로 표현됐습니다. OBS로부터 전달받은 얼굴의 좌표가 중앙에 위치할 수 있도록 position을 정의할 필요가 있었습니다. 이미지를 9개 영역으로 나눠 영역에 맞춰 클래스를 분기 처리하면 되지 않을까 생각했습니다. 

  • 클래스 구분 형식 제안
    1. 이미지 영역을 아래 표와 같이 9개로 분할
    2. 얼굴 좌표의 중심이 9개 영역 중 영역 1~9에 있을 경우 영역에 알맞은 클래스를 분기 처리
leftcenterright
topL TC TR T
middleL MC MR M
bottomL BC BR B
.L : {background-position-x: left;}
.C : {background-position-x: center;}
.R : {background-position-x: right;}
.T : {background-position-y: top;}
.M : {background-position-y: center;}
.B : {background-position-y: bottom;}

만약 좌표의 중심이 RM 영역에 있다면 .R.M 클래스를 분기 처리해 아래처럼 스타일이 적용될 수 있도록 하는 방식입니다.

.R {background-position-x: right; }
.M {background-position-y: center; }

@이상원
“은재 님께서 제안해 주신 방식을 처음 받았을 때 괜찮은 방법이라고 생각해 간략한 코드로 먼저 구현해 보았습니다. Web Timeline에서 이미지의 크기는 어느 정도 제한하지만 이미지의 형태에는 제한을 두지 않기 때문에 다소 극단적인 케이스를 검증해 볼 필요가 있었는데요. 이미지를 생성해 테스트하는 도중 문제점을 발견했습니다.”

테스트하기 위해 아래와 같이 이미지의 1/3 지점에 얼굴이 배치되어 있고 가로 크기가 세로에 비해 3배가량 큰 이미지를 생성했습니다.

위 샘플 이미지를 제안된 방식으로 표현한다면 left나 center 중 어느 클래스에 맞춰도 얼굴이 중앙에 정렬되지 않아 스펙을 충족할 수 없습니다. 또한 세로로 긴 이미지에서도 이와 동일한 현상이 발생할 것으로 예상됐습니다. 은재 님께 이 내용을 공유하고, 결국 위치 값을 전달하는 형태로 제작하는 것으로 협의했습니다.

 

픽셀을 사용한 position 정렬

인라인 스타일(inline style)의 형태로 직접 x 위치 또는 y 위치 값을 추가하는 방식을 생각해 봅시다. background-position에 전달할 수 있는 단위로 가장 먼저 생각할 수 있는 건 퍼센티지(percentage, 이하 %)와 픽셀(이하 px)이었습니다. 우선 https://drafts.csswg.org/의 CSS Backgrounds and Borders Module Level 3 문서에서 %의 스펙을 확인했습니다.

For example, with a value pair of 0% 0%, the upper left corner of the image is aligned with the upper left corner of, usually, the box’s padding edge. A value pair of 100% 100% places the lower right corner of the image in the lower right corner of the area. With a value pair of 75% 50%, the point 75% across and 50% down the image is to be placed at the point 75% across and 50% down the area.

위 설명에 따르면 x 좌표를 계산해 33.3%가 나왔을 때 33.3%라는 값을 그대로 스타일로 설정하면 프레임의 중앙에 이미지의 33.3% 영역이 정렬되는 것이 아니라, 프레임의 33.3% 위치에 이미지의 33.3%가 위치하게 됩니다. 샘플로 만들었던 이미지를 예로 들면, 아래와 같이 얼굴이 중앙에 정렬되지 않고 프레임 좌측으로 조금 치우쳐서 위치하게 됩니다.

이와 같이 CSS의 % 스펙이 복잡하고, getPosition에서 구한 좌표를 비율 변환을 통해 px 값으로 활용할 수 있어서 구현이 간단해진다는 장점을 고려해, 먼저 px로 간단한 샘플을 구현했습니다. 

position 값은 이미지를 중심으로 x, y의 좌표를 나타냅니다. 저희는 cover를 사용하여 컨테이너에 맞추도록 협의했으므로, 이에 맞춰 position을 조절해 주어야 합니다. 먼저 cover 스펙은 컨테이너 크기에 맞춰 이미지를 최대한 크게 조절하는 스펙이므로, 가로와 세로 중 맞춰진 면이 존재하게 됩니다. 가로나 세로에 맞춰진 면은 100 % 노출되게 되므로 position을 조절할 필요가 없습니다. 즉, 컨테이너에서 벗어나는 면에만 position 조절이 필요합니다. 두 번째 조건은 이미지가 컨테이너 사이즈에 맞춰 조절되므로 position의 x, y 값도 비율에 맞춰 수정해야 합니다. 세 번째 조건은 컨테이너에서 이미지가 노출될 때 빈 공간이 발생하지 않아야 합니다. cover 스펙만으로는 발생하지 않지만, position을 임의로 조절하면서 발생하는 이슈입니다.

위와 같이 크게 세 가지 조건을 정리해 아래와 같이 스펙을 만들었습니다.

  • position 조절 조건
    • 가로와 세로 중 딱 맞춰진 면을 제외한 다른 면의 position을 조절
    • position은 이미지가 조절된 비율에 맞춰 수정
    • 이미지를 감싼 컨테이너에서 빈 공간이 발생하지 않아야 함

이를 코드로 구현한 모습입니다.

style px 구하는 코드

// cover spec에 맞춰 변환된 이미지의 비율을 구합니다
const getRatio = (imageWidth, imageHeight, domWidth, domHeight) => {
  return Math.max(domWidth / imageWidth, domHeight / imageHeight);
};
 
// 컨테이너에서 빈 공간이 생기지 않도록 position을 조정합니다.
const getPosition = (domSize, imageSize, position) => {
  const point = domSize / 2 - position;
  if (point > 0) return 0;
  return point - domSize < -imageSize ? domSize - imageSize : point;
};
 
const ratio = getRatio(width, height, offsetWidth, offsetHeight);
 
// position을 구합니다.
const position = {
  x: isVertically ? getPosition(offsetWidth, imageWidth * ratio, centerPosition.x * ratio) : 0,
  y: isVertically ? 0 : getPosition(offsetHeight, imageHeight * ratio, centerPosition.y * ratio),
}

 

픽셀 단위를 React에 적용한 후 발견된 이슈

Web Timeline은 브라우저에서 실행되며 이미지를 감싸는 컨테이너의 크기는 윈도 크기에 따라 변경될 가능성이 있습니다. 따라서 윈도 크기가 변경될 때마다 컨테이너의 크기를 다시 확인하고 px 단위를 계산해야 합니다. 이 때문에 React에서 윈도 크기가 변경될 때 re-render를 발생시키도록 해줘야 했는데요. React의 useEffect는 기본적으로 윈도 크기 변경에 반응하지 않습니다. 즉, 이미지 컨테이너의 크기가 변경되면 useEffect가 발생하도록 유도해야 했는데요. 이미지 컨테이너의 크기를 useState에 저장하고, 이를 useEffect에서 감지하는 과정을 추가하는 것이 불가피해 보였습니다. 이를 훅(hook) 등으로 만들어 사용하면 조금 원활하게 사용할 수 있겠지만, 이미지 position을 조절하는 스펙을 여러 군데 사용해야 하는 상황에서 훅을 항상 추가해야 한다는 것은 불편하게 느껴졌습니다.

px을 적용한 결과 문제점이 발견되었으니 % 스펙으로 눈을 돌려 보았습니다. % 스펙에서는 돔 크기가 변경되더라도 cover 스펙에 따라 이미지도 동일한 비율로 변경됩니다. 돔 크기와 이미지가 같은 비율로 변경되기 때문에 % 값은 변하지 않습니다. 돔 크기 변경에 민감한 px보다는 %로 position을 전달하는 것이 장점이 더 많다고 판단해서 %를 검토할 필요가 생겼습니다. 이때 마침 은재 님께서 %를 사용해 position을 정렬하는 방법을 제안해 주셨습니다.

@김은재
“px과 %가 다르게 동작하는 것을 이해했고, CSS background-position의 % 스펙을 다시 살펴보게 되었습니다. CSS background-position의 % 단위를 사용해 얼굴 좌표 기준으로 이미지를 컨테이너 중앙에 위치시키려면 값을 어떻게 지정해야 할지 고민했습니다.”

 

%를 사용한 position 정렬

우선 px과 % 단위의 차이점을 다시 살펴봅시다. 아래와 같이 px 값의 경우 선언한 값 그대로 오프셋(offset)이 적용되는 반면, % 값은 수평에 대한 오프셋(x-offset)은 ‘container width – image width’를 기준으로 하고, 수직에 대한 오프셋(y-offset)은 ‘container height – image height’을 기준으로 합니다(여기서 이미지의 크기는 background-size 혹은 object-fit으로 지정된 크기를 의미). 따라서 background-position-x: 75%라고 선언한다고 해서 컨테이너의 중심에 이미지의 75% 지점이 맞춰지는 것이 아니라, 컨테이너의 75% 지점에 이미지의 75% 지점이 맞춰집니다.

px로 선언한 경우

position px value = offset value

%로 선언한 경우

(container width - image width) * (position percentage value) = x offset value
(container height - image height) * (position percentage value) = y offset value

하지만 얼굴인식 기능을 구현하기 위해서는 컨테이너 중심과 이미지의 얼굴 좌표를 정렬시켜야 했습니다. 그렇기 때문에 기존과 다른 방식으로 접근해야 했습니다. 컨테이너 크기와 이미지 크기, 이미지의 얼굴 좌표 정보. 이 세 가지 정보를 활용해 얼굴 좌표가 컨테이너의 중앙에 위치하도록 background-position에 % 값을 어떻게 선언했는지 예시와 함께 알아보겠습니다. 

아래와 같이 너비 1000px, 높이 100px 크기의 이미지가 있고, 이 이미지가 표현될 컨테이너의 크기는 너비 200px, 높이 100px라고 가정해 보겠습니다(background-size: auto입니다).

background-position-x: 50%로 선언한 경우에는 아래 이미지처럼 컨테이너의 중심(50%)과 이미지의 중심(50%)이 맞춰집니다. 

background-position-x: 100%로 선언한 경우에는 아래 이미지처럼 컨테이너의 100% 지점과 이미지의 100% 지점이 맞춰져 컨테이너의 중심에 이미지의 900px 지점이 위치합니다.

background-position-x를 50%로 선언했을 때와 100%로 선언했을 때를 비교하면, 아래와 같이 이미지의 중심(500px)이 900px까지 A만큼 이동했습니다.

그렇다면 이미지의 특정 좌표를 컨테이너 중앙에 맞추고 싶다면 background-position 값을 어떻게 설정해야 할까요? 예를 들어, 이미지의 1000px 지점과 컨테이너의 중앙을 맞춘다고 가정해 보겠습니다.

아래와 같이 이미지 중앙에서부터 (5/4)*A 만큼 이동하면 컨테이너 중심에 이미지의 100% 지점인 1000px을 맞출 수 있습니다.

위 그림을 식으로 옮겨보면 다음과 같습니다.

x% = 50% + 5/4 * 50%
= 112.5%

공식에 따라 background-position-x: 112.5%로 선언하면 아래와 같이 이미지의 100%를 컨테이너의 중심에 맞출 수 있습니다.

컨테이너 대비 이미지의 확대 혹은 축소비율을 구하고 이미지 중심으로 얼마나 움직이는지를 계산하면 됩니다. 공식 중 ‘5/4’라는 값에 대해 자세히 살펴보면, 5는 컨테이너에 대한 이미지의 크기 비율을 의미하고, 4는 실제로 이미지 내부에서 컨테이너가 움직일 수 있는 구간을 의미합니다. 이 값은 background-position: 1%가 적용될 때 움직이는 비율입니다. background-position: 50% (이미지의 중앙) 값을 기준으로, background-position < 50% 일 때 좌측으로 5/4 비율만큼 중심이 얼마나 움직이고, background-position > 50% 일 때 우측으로 5/4 비율만큼 중심이 얼마나 움직이는지를 계산하면 됩니다.

background-position에 1%가 적용될 때 움직이는 비율을 구하는 공식은 다음과 같습니다.

  • 컨테이너에 대한 이미지 사이즈 비율: (image width / image height) / (container width / container height) = 5
  • 컨테이너가 배경 이미지 안에서 실제 움직일 수 있는 구간: ((image width - container width) / image height) / (container width / container height) = 4
  • 공식: (image width / image height) / (container width / container height) / ((image width - container width) / image height) / (container width / container height)

 

도출된 비율 공식을 코드로 전환

위 공식 내용을 아래와 같은 코드로 정리할 수 있습니다.

ratio 계산

// horizontal image (background-position-x)
ratio = (image width / image height) / (container width / container height)
 
// vertical image (background-position-y)
ratio = (image height / image width) / (container height / container width)
 
areaRatio = ((imageWidth - containerWidth) / imageHeight) / (containerWidth / containerHeight);
percentage ratio = ratio / areaRatio;
 
position percentage value = (image percentage - container percentage) * percentage ratio + container percentage;
// image percentage: container percentage에 위치시킬 image position의 percentage
// container percentage: image를 위치시킬 container percentage. (여기서는 이미지의 얼굴 좌표를 컨테이너 중앙에 놓는 스펙이기 때문에 50% 값을 갖는다)

컨테이너와 이미지 사이의 비율과 정렬하고 싶은 이미지의 좌표를 안다면 컨테이너의 중앙에 위치시킬 background-position의 % 값을 구할 수 있습니다. background-size: cover로 선언한 경우, 컨테이너의 크기가 증가함에 따라 background-size로 정의한 이미지의 크기 또한 같은 비율로 증가하므로 위 공식을 이용해 background-position의 percentage 값을 구할 수 있습니다. 

그런데 이때 percentage 값이 0보다 작거나 100보다 크다면 이미지 기준으로 좌측 혹은 우측에 빈 공간을 노출하게 됩니다. Web Timeline 스펙에는 이미지가 표현될 때 빈 공간을 노출하지 않는다는 항목이 있으므로 % 값이 0보다 작을 때는 0%, % 값이 100보다 클 때는 100%로 값을 지정해 줬습니다.

 

느낀 점

@이상원
“잘 알고 있다고 생각했던 스펙들도 막상 자세히 되짚어보면 내가 정말 잘 알고 있는 것인가 의문이 드는 경우가 있는데요. 이번 작업을 진행하며 다시 한번 그런 경험을 하게 된 것 같습니다. CSS에서 background-position을 자주 사용했었지만, percentage 값이 적용되는 공식이나 스펙 등에 대해 자세히 알고 있지는 못했었는데요. 이번 프로젝트를 진행하면서 꼼꼼히 스펙을 되짚어 보며 깊게 공부해 볼 수 있는 좋은 계기가 되었습니다.

함께 알아보며 고민하고 큰 도움 주신 은재 님께 진심으로 감사드립니다.”

@김은재
“상원 님과 논의하며 이미지를 특정 위치에 정렬시키기 위해 어떤 단위를 써야 하고, 값을 어떻게 지정해 줘야 더 효율적일지 함께 고민해 보는 계기가 되었습니다. 그리고 자주 사용하지만 놓치고 있었던 CSS 스펙에 대해 다시 한번 살펴보고 정확하게 이해하는 계기가 되었습니다.

많은 도움 주신 상원 님께 진심으로 감사드립니다!”


 

  1. cover: 컨테이너를 채울 수 있도록 이미지를 최대한 크게 조정하고 필요한 경우 이미지를 늘립니다. 이미지의 비율이 컨테이너와 다른 경우 빈 공간이 남지 않도록 세로 또는 가로로 잘립니다.