필요 지식
#node #sharp #async

npmjs.com 에 등록된 많은 패키지 중 이미지 처리에 매우 좋은 sharp 패키지를 이용하여 이미지 자동 분할 기능을 만들어보겠습니다.

경로 : https://www.npmjs.com/package/sharp
api : http://sharp.pixelplumbing.com/en/stable/
설치 : npm install sharp
현재버전 : 0.21.3

쇼핑몰에 제품 카탈로그 이미지를 보면 한개의 이미지가 여러 이미지의 혼합으로 이루어진 경우를 보실 수 있습니다.(물론 모든 쇼핑몰이 그런것은 아닙니다.)

그러한 특정 기준으로 자동으로 분할 저장하는 기능을 만들어보려고 합니다.(이런 기능이 정말 쓸데가 있을까 싶네요)

여기서 사용할 이미지는 이전 내용에서 다루었던 샘플과 동일하며 역시 맨 하단에 붙이겠습니다.

구현 목표

아래 이미지는 샘플이미지의 일부입니다.

이미지구성

위 이미지는 총 3개의 이미지로 구성되어있고 흰색 여백이 이미지를 구분짓는 여백 공간이라는 것을 볼 수 있습니다.
여기서 만들 코드는 세로로 분할된것은 체크하지 않고 가로로 분할된 이미지인지만 확인하여 잘라낼 것입니다.
즉 위 이미지에선 1번과 2번 이미지가 하나의 이미지로, 3번이 하나의 이미지가 될 것입니다.

구현 전략

이미지가 분할된 이미지인지 아닌지 판단하기 위한 유일한 기준은 바로 가로 여백입니다. 아래 이미지를 보면 어떻게 판단하려고 하는것인지 쉽게 이해하실수 있을 겁니다.

전략

보라색 영역이 나타내는 것처럼 위에서 아래로 가로줄을 분석하여 해당 줄이 흰색이면 가로 여백으로 판단을 할 것입니다.

  1. 이미지의 상단에서 하단으로 가로 라인의 색상 추출
  2. 추출된 색상이 흰색인지 확인
  3. 흰색이면 여백 라인으로 판단하고, 이전 이미지 영역부터 여백 라인까지 crop
    이전 이미지 영역은 여백라인이 아닌 부분을 의미

실제 구현

구현을 위한 로직 순서는 아래와 같습니다.

  1. 이미지 사이즈 구하기
  2. 특정 위치의 가로 라인 색상 추출
  3. 가로 라인의 색상 평균값을 구한다
  4. 평균값이 흰색일 경우 이미지 crop
  5. 2~4를 이미지 상단에서 하단까지 반복실행

하나씩 만들어보겠습니다.

이미지 사이즈 구하기

sharp에서 metadata() 함수를 통해 이미지의 정보를 구할 수 있습니다.
아래 항목 이외에도 더 많은 정보를 알아낼수 있으니 자세한 내용은 metadata api 를 확인해보시기 바랍니다.

  • format: Name of decoder used to decompress image data e.g. jpeg, png, webp, gif, svg
  • size: Total size of image in bytes, for Stream and Buffer input only
  • width: Number of pixels wide (EXIF orientation is not taken into consideration)
  • height: Number of pixels high (EXIF orientation is not taken into consideration)
  • space: Name of colour space interpretation e.g. srgb, rgb, cmyk, lab, b-w …

metadata() 함수에서 콜백함수를 사용하는 방법으로 만들면 아래와 같이 할 수 있습니다.

  1. let image = sharp(imagePath);
  2. image.metadata().then(function(metadata) {
  3. let imgHeight = metadata.height; //이미지 높이
  4. let imgWidth = metadata.width; //이미지 넓이
  5. let blankWidth = imgWidth / 2; //여백인지 확인할 넓이
  6. let blankLeft = blankWidth / 2; //여백인지 확인할 위치
  7. const blankHeight = 5; //여백인지 확인할 높이
  8. });

위에서 구한 이미지의 높이와 넓이값으로 여백 여부를 확인할 것입니다.
blankXXX 변수가 여백을 확인때 사용할 변수인데 값의 의미는 아래와 같습니다.

여백 기준

이미지의 좌우 끝엔 여백이 있을 가능성이 높으므로 이미지의 가운데 영역으로만 여백인지 확인하는데 사용할 것입니다. 물론 이러한 룰은 각자 자신만의 룰을 적용하면 됩니다.

이미지의 일부분 추출하기

이미지의 일부가 여백인지 확인하는 것은 곧 이미지의 일부를 추출한다는 것입니다. 따라서 extract() 함수를 사용할 수 있습니다.

  1. function checkBlankPart(areaInfo, cb) {
  2. image.extract(areaInfo)
  3. .toBuffer((err, data, info) => {
  4. if(err) {
  5. console.log(areaInfo);
  6. throw new Error(err);
  7. }
  8. sharp(data).stats().then(({channels: [rc, gc, bc]}) => {
  9. //RGB 컬러의 평균값을 구한다
  10. const r = Math.round(rc.mean),
  11. g = Math.round(gc.mean),
  12. b = Math.round(bc.mean);
  13. if(r >= 230 && g >= 230 && b >= 230) { //255의 90% 값
  14. cb(true);
  15. return;
  16. }
  17. cb(false);
  18. });
  19. });
  20. }
  21. checkBlankPart({left:blankLeft, top:0, width:blankWidth, height:blankHeight}, function(isBlank) {
  22. });

이전 글에서 extract() 함수로 이미지를 추출하여 파일로 저장하는 예를 보였습니다. toFile() 함수대신 toBuffer()함수를 사용하면 데이터 버퍼 형태로 받을수가 있습니다.

이미지의 데이터 버퍼를 sharp 로 변환하고 stats() 함수를 통해 RGB컬러 값을 구할 수 있습니다. mean 속성은 이미지의 평균값을 가지고 있습니다. 즉 이미지의 평균 컬러값이 흰색으로 판단되면 여백으로 판단하겠다는 로직입니다.

물론 흰색은 RGB컬러값이 255, 255, 255 가 맞지만 흰색에 가까운 값도 여백으로 취급하기 위해 90% 값인 230을 기준으로 설정했습니다. 이 설정 또한 각자의 룰을 만들면 됩니다.

위 소스 코드는 이미지 높이의 0px 을 기준으로 확인하는 코드이므로 이미지 높이만큼 반복실행하면 됩니다.

이미지 여백 확인 반복 실행

checkBlankPart() 함수를 실행할때 이미지 높이 만큼 반복 실행하려면 아래와 같이 할 수 있습니다.

  1. for(let i = 0; i < imgHeight - blankHeight; i += blankHeight) {
  2. checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}, function(isBlank) {
  3. });
  4. }

그러나 extract() 함수나 stats() 함수가 비동기 함수이기 때문에 checkBlankPart() 함수 또한 비동기 함수로 동작되며 위와 같은 루프는 실행 순서를 보장하지 못합니다.

따라서 실행 순서를 보장하기 위해 async 패키지를 이용하려고 합니다. async 패키지에 대해서는 다른 글에서 한번 다뤄보겠습니다.

  1. const async = require("async");
  2. let i = 0;
  3. async.whilst(
  4. function condition() { //반복실행 조건
  5. return i < imgHeight - blankHeight;
  6. },
  7. function process(cb) { //처리
  8. checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}, function(isBlank) {
  9. cb();
  10. });
  11. },
  12. function done(err) { //루프 종료시
  13. }
  14. );

async 패키지의 whilst() 함수를 사용하였습니다. 이 함수는 파라미터로 함수 3개를 지정해주는데 익명함수로 작성하면 되지만 용도를 알기 쉽게 각각 condition, process, done 이름을 지정해주었습니다.

이로써 이미지의 0px위치에서 하단까지 checkBlankPart() 함수로 여백영역인지 확인하게 될 것입니다.

blankHeight 값이 작을수록 세세하게 체크하게 되는데 그만큼 처리 시간이 오래 걸립니다.

이미지 crop 저장

이제 남은것은 여백으로 판단된 공간을 기준으로 이미지를 추출해서 저장하면 됩니다.
checkBlankPart() 함수는 콜백함수로 isBlank 변수를 전달해주는데 true일때 여백 라인이라고 생각하면 됩니다.

  1. let cropInfo = {
  2. mode : false,
  3. prevTop : 0
  4. };
  5. checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}, function(isBlank) {
  6. if(isBlank) { //현재 위치가 여백 라인이라면
  7. let extractHeight = i - cropInfo.prevTop;
  8. if(!cropInfo.mode && extractHeight > 200) { //200픽셀보다 커야 의미있는 이미지
  9. cropInfo.mode = true;
  10. let option = {left:0, top:cropInfo.prevTop, width:imgWidth, height: extractHeight};
  11. console.log(option);
  12. image.extract(option).clone().toFile(i + ".jpg");
  13. cb();
  14. return;
  15. }
  16. cropInfo.prevTop = i;
  17. }
  18. else
  19. cropInfo.mode = false;
  20. console.log(i);
  21. i += blankHeight;
  22. cb();
  23. });

여백라인일때 사용하는 조건절인 !cropInfo.mode && extractHeight > 200은 꽤 중요합니다. !cropInfo.mode 는 여백라인 다음 값도 여백라인일때 이미지를 반복적으로 처리하지 않기 위해 사용하는 조건입니다. extractHeight > 200 조건은 너무 작은 이미지는 의미가 별로 없는 이미지 일거라는 나름의 기준입니다.(아이콘, 버튼 이미지 등)

파일로 저장할때 extract() 함수 뒤에 clone() 함수를 호출했습니다. 왜 했을까요? 한번 이유를 찾아보시면 좋을것 같습니다.

이제 소스는 완성되었고 최종 완성 소스는 아래를 참고하시면 됩니다.

정리

샘플을 실행해보면 원본 이미지에서 몇개의 이미지가 추출되어 저장된것을 보실수 있습니다.
그러나 이 소스는 몇가지 개선점이 있습니다.

  1. 콜백 함수 구조 개선
    함수의 콜백구조는 비동기 함수에선 당연한 것이지만 영 좋지 않습니다. 따라서 다음 글에선 같은 소스를 async/await를 이용하여 개선해보도록 하겠습니다.
  2. 느린 실행 속도
    제 PC의 경우 실행이 종료될때까지 약 70초 정도가 걸립니다. 당연히 튜닝이 필요하겠죠. 이건 다다음 글에서 한번 다뤄보겠습니다.

최종 샘플

  1. const sharp = require("sharp");
  2. const async = require("async");
  3. let imagePath = "sharp.jpg";
  4. function checkBlankPart(areaInfo, cb) {
  5. image.extract(areaInfo)
  6. .toBuffer((err, data, info) => {
  7. if(err) {
  8. console.log(areaInfo);
  9. throw new Error(err);
  10. }
  11. sharp(data).stats().then(({channels: [rc, gc, bc]}) => {
  12. //RGB 컬러의 평균값을 구한다
  13. const r = Math.round(rc.mean),
  14. g = Math.round(gc.mean),
  15. b = Math.round(bc.mean);
  16. if(r >= 230 && g >= 230 && b >= 230) { //255의 90% 값
  17. cb(true);
  18. return;
  19. }
  20. cb(false);
  21. });
  22. });
  23. }
  24. let start = new Date().getTime();
  25. let image = sharp(imagePath);
  26. image.metadata().then(function(metadata) {
  27. let imgHeight = metadata.height; //이미지 높이
  28. let imgWidth = metadata.width; //이미지 넓이
  29. let blankWidth = imgWidth / 2; //여백인지 확인할 넓이
  30. let blankLeft = blankWidth / 2; //여백인지 확인할 위치
  31. const blankHeight = 5; //여백인지 확인할 높이
  32. let i = 0;
  33. let cropInfo = {
  34. mode : false,
  35. prevTop : 0
  36. };
  37. async.whilst(
  38. function condition() { //반복실행 조건
  39. return i < imgHeight - blankHeight;
  40. },
  41. function process(cb) { //처리
  42. checkBlankPart({left:blankLeft, top:i, width:blankWidth, height:blankHeight}, function(isBlank) {
  43. if(isBlank) { //현재 위치가 여백 라인이라면
  44. let extractHeight = i - cropInfo.prevTop;
  45. if(!cropInfo.mode && extractHeight > 200) { //200픽셀보다 커야 의미있는 이미지
  46. cropInfo.mode = true;
  47. let option = {left:0, top:cropInfo.prevTop, width:imgWidth, height: extractHeight};
  48. console.log(option);
  49. image.extract(option).clone().toFile(i + ".jpg");
  50. cb();
  51. return;
  52. }
  53. cropInfo.prevTop = i;
  54. }
  55. else
  56. cropInfo.mode = false;
  57. console.log(i);
  58. i += blankHeight;
  59. cb();
  60. });
  61. },
  62. function done(err) { //루프 종료시
  63. console.log("done");
  64. let end = new Date().getTime();
  65. console.log((end - start) / 1000);
  66. }
  67. );
  68. });

샘플이미지