스캔 문서 이미지를 흑백으로 만들어 보자 (feat imagemagick)
How to change grey background into white on scanned document?
Intro
칼라로 스캔된 고서 이미지 수십장을 열람용으로 출력해야 했다. 그렇다. 무려 동의보감이다.
다음과 같은 페이지를
아래와 같이 바꿔야 했다.
칼라 그대로 인쇄하자니 비용이 많이 들고, 흑백으로 뽑으면 배경색 때문에 텍스트가 깨끗하게 보이지 않는다.
따라서 내가 해야할 일은 크게 2가지이다.
가지있는 칼라 이미지 배경을 흰색으로 바꿔라!
출력에 적합하게 잘라라! (Crop)
TASK1 : 칼라 이미지 배경을 흰색으로 바꿔라
삽질1: 맨손에서 ImageMagick까지
목표는 명확해 졌으니 방법을 찾을 차례다. 그림 파일은 다루어 본 적이 없고, 그나마 윈도우에서 제공하는 기본 프로그램으로 자르고 붙였던 것이 전부였다. 그런 나에게 너무나 높은 목표였다.
포기하기엔 아직 이르다. “하지만 소신에게는 아직 ubuntu와 ruby가 있나이다!”
그렇다. 생활자동화에서 빼놓을 수 없는 나의 무기는 ruby이다. (ruby는 windows와 상성이 그리 좋지 않은 듯)
처음에는 ruby에 image를 다룰 수 있는 package를 살펴 보다가 MiniMagick을 알게 되었다. 이로부터 다시 Rmagic을 알게 되었다. 그렇다. ruby image package의 본좌는 Rmagic이었다.
Rmagic은 linux의 image processing library인 ImageMagick를 다루기 위한 ruby package이다. 그러니까 imagimagic이 설치된 linux machine에서만 활용할 수 있다. RMagick 홈페이의 소개문구이다.
RMagick is an interface between the Ruby programming language and the ImageMagick image processing library.
그리하여 우선 ubuntu에 imagemagic을 설치했다. 사실을 설치되어 있었다. 아마 당신의 ubuntu에도 이미 설치되어 있을 가능성이 크다. 배포판에 기본적으로 들어 있는 듯 하다. 만약 새로 설치해야 한다면 다음과 같이 설치하면 된다.
sudo apt-get install imagemagick
이제 준비가 끝났다.
삽질2 : ImageMagick의 image#channel method까지
ImageMagick을 이용하면 된다는 것까지 알았는데, 방대한 분량의 library라서 내용을 이해할 수 없었다. 일단 포기.
Rmagic의 document가 보다 직관적이어서 이것을 살펴 보기 시작 했다. 어디서부터 시작해야 할지 몰라 무식하게 instance method를 하나하나 보기 시작했다. 다행히 설명과 예시가 잘 정리되어 있어서 대충 어떤 것인지 알 수 있었다.
그 가운데 가장 가까워 보이는 것이 image#channel
method 였다. 설명은 아래와 같이 되어 있는데, 분명한 것은 색깔을 바꿀 수 있다는 점이다.
Extracts a channel from the image. A channel is a particular color component of each pixel in the [image].
우선 이 method를 가지고 test를 시작했다. 이 method는 인자로 주어지는 ChannelType
에 따라 다른 결과를 도출한다. ChannelType이 여러개라는 것이 문제. 별 수 있는가? 모르면 손발이 고생이다. 아래와 같이 모두 돌려 보았다.
require 'rmagick'
include Magick
img = Image.read('myimage.jpg').first
channels = [
["UndefinedChannel", Magick::UndefinedChannel],
["RedChannel", Magick::RedChannel],
["GreenChannel", Magick::GreenChannel],
["BlueChannel", Magick::BlueChannel],
["CyanChannel", Magick::CyanChannel],
["MagentaChannel", Magick::MagentaChannel],
["YellowChannel", Magick::YellowChannel],
["BlackChannel", Magick::BlackChannel],
["OpacityChannel", Magick::OpacityChannel],
["AllChannels", Magick::AllChannels],
["GrayChannel", Magick::GrayChannel],
["AlphaChannel", Magick::AlphaChannel],
["DefaultChannels", Magick::DefaultChannels],
["HueChannel", Magick::HueChannel],
["LuminosityChannel", Magick::LuminosityChannel],
["SaturationChannel", Magick::SaturationChannel]
]
channels.each do |c|
img.channel( c[1] ).write( "#{c[0]}.jpg")
end
결과는 이렇다.
YellowChannel
BlueChannel
GreenChannel
RedChannel
GrayChannel
그 결과 내가 작업에서 가장 무난한 ChannelType
은 Magick::GrayChannel
였다.
하지만 바탕이 완전히 흰색으로 바뀐 것은 아니었기 때문에 만족할 수 없었다.
삽질3 : textcleaner까지
역시 다시 google 신에게 물어 보기로 했다. 그러다 찾은 곳이 요기, Fred’s ImageMagick Scripts 페이지였다. Fred라는 양반이 ImageMagick에 활용할 수 있는 다양한 script를 모아 놓았더라.
이 스크립트 가운데 textcleaner
가 있었다. 설명을 보니 내가 찾는 그것이었다. 해당 페이지에 예시까지 깔끔하게 올려 두셨다. 이자리를 빌어 Fred께 감사를 드린다.
Processes a scanned document of text to clean the text background
나는 이 때 ruby를 써야 한다고 생각한 나머지 textcleaner 코드를 보고 Rmagick에 맞게 ruby 코드를 다시 짜야 한다고 생각하고 있었다. 하지만 textcleaner script는 bash script여서 내가 알아 볼 수 없었다. 가장 중요한 마지막의 imagemagick 명령어가 핵심인 것 같아 들여다 봤으나 option flag value 들의 의미를 전혀 알 수 없었다. 그것을 이해하기 위해서는 사실상 imagemagick을 거의 이해하고 있어야 했다. 거의 답을 찾은 것 같았는데, 콱 막힌 셈이다.
다음날 가만히 생각해 보니 그냥 ruby로 system 명령을 내려 textcleaner script를 bash shell에서 바로 사용하면 되겠구나 싶었다. 어제 난 왜 그 고민을 한 것일까 …. (나중에 알게된 사실이지만, 다른 언어로 컨버팅 하는데 대한 라이센스가 걸려 있더라) 그래서 짠 것이 [다음의 코드(https://github.com/pinedance/snippets/tree/master/textcleaner/ruby.version)
# textcleaner.rb
imgsNs = Dir["./img/**/*.jpg"].sort # image file이 img directory 아래 폴더별로 묶여 있다.
imgsNs.each do | imgnm |
newpath = File.dirname(imgnm).sub("/img/", "/imgr/")
newimgnm = imgnm.sub("/img/", "/imgr/")
next if File.exist?(newimgnm)
Dir.mkdir( newpath ) if !Dir.exist?(newpath)
cmdmain = "./textcleaner -g -e stretch -f 25 -o 5 -s 1"
cmd = "#{cmdmain} #{imgnm} #{newimgnm}"
puts cmd
system(cmd)
end
textcleaner의 옵션값이 어떤 의미가 있는지 옵션값을 변경하면서 text를 했는데, 여기서는 생략한다. 아마 주어진 task에 따라서 달리해야 할 듯하다.
이리하여 TASK1을 끝낼 수 있었다.
추가로 작은 문제들을 적는다. 작은 문제는 정신건강상 그냥 넘어가기로 했다.
textcleaner가 내부적으로 쉬운 작업은 아닌 것 같다. 1개의 파일을 변경하는데 시간이 좀 걸린다.
변경하면 원래 파일보다 파일 크기가 커진다. 왜 그런지 모르겠다. 아무튼 1.5배정도 커지는 것 같다.
추가. 나중에 ruby를 이용하지 않고 bash script로 textcleaner를 실행시켰다. 코드는 여기에 있다.
TASK2 : 출력에 적합하게 잘라라! (Crop)
이미지 크기를 맞추다
이미지를 적당한 크기로 자르는 것은 비교적 간단해 보였다. 이번에는 RMagick에서 image#crop method를 이용해서 이미지를 잘랐다.
그런데 문제가 생겼다. 몇몇 이미지에서 원하지 않는 모양으로 잘려 나왔다. 이유를 알 수 없었다. RMagick package의 오류인가? 시스템 문제인가? 아무래도 알 수가 없었다.
역시 해답은 자고 난 뒤 아침에 나온다. 이미지 뷰어로 봤을 때는 전체화면으로 놓고 봐서 몰랐는데 이미지 파일들이 비율은 같았지만 pixel이 서로 달랐다. 즉 픽셀이 다른 2, 3종이 섞여 있었던 것이다. crop 기준이 절대값인 pixel이기 때문에 내가 샘플로 pixel을 계산한 이미지 보다 큰 이미지에서는 작은 일부만 잘려 나오고, 보다 작은 이미지에서는 더 큰 부분이 잘려 나왔던 것이다.
이 문제를 해결하기 위해서는 그림 크기를 서로 맞춰야 했다. RMagick에서는 image#resize, image#resize_to_fill, image#resize_to_fit 등 다양한 method들을 제공하고 있었다. 선택권이 많았지만 어떻게 써야하는지 정확하게 모르기 때문에 이것도 삽질이 필요했다. 이렇게 저렇게 테스트를 반복해 보다가 resize_to_fit이 내가 원하는 작업에 가장 적당하다는 것을 알게 되었다.
시스템을 죽이지 말고 살려라
드디어! resize_to_fit
을 통해 image를 모두 같은 하나의 크기로 만든 뒤에 crop
으로 image를 잘랐다.
그런데 또 예상치 못한 문제가 생겼다. 여러 이미지를 each method로 반복처리 하고 있었는데, 처음에는 잘 돌아가다가 점점 느려지는 것이었다. 그뿐 아니라 그런가 싶다가 죽어버리더라. 처음 MiniMagick package를 살펴볼 때 RMagick이 본좌이긴 한데 메모리를 너무 많이 먹어…. 뭐 이런 문구를 본 것 같았다. memory leak인가?
용량이 큰 이미지가 계속 메모리에 올라가니 나중에 가득 차서 문제가 생긴 것 같다. 이게 overflow인가?
아무튼 다시 구글신께 문의 했다. 같은 질문을 올린 사람들이 있더라.
이 문제는 image#destory!
method로 해결할 수 있었다. 반복 시행 말미에 사용했던 임시 변수들을 비워줬다.
마침내 처음부터 끝까지 쌩쌩하게 잘 돌아가더라.
# crop.rb
require 'rmagick'
include Magick
imgsNs = Dir["./imgr/**/*.jpg"].sort # 앞에서 흑백으로 만든 파일이 `./imgr/` 안에 들어 있었다.
# 적당히 앞에서 5번째 파일 크기를 기준으로 크기를 조정하련다.
original = Image.read(imgsNs[4]).first
originalSize = {:columns => original.columns, :rows => original.rows}
wd, hg = 2200, 334 # 자를 가로와 세로 크기. 그림 보고 재는 수 밖에 없다.
x1, x2, y = 2650, 750, 40 # 왼쪽 위 꼭지점에서 얼마나 떨어진 부분 부터 자를 것인가
imgsNs.each do |imgnm|
newpath = File.dirname(imgnm).sub("/imgr/", "/imgz/" # 새로운 결과를 `./imgz/` 안에 넣으련다.
Dir.mkdir( newpath ) if !Dir.exist?(newpath) # 해당 폴더가 없다는 에러를 만나기 싫다면
basen, dot = File.basename(imgnm).split(".")
# 중간에 시스템이 죽으면 했던 작업 다시 안하려고 넣은 코드
if File.exist? File.join( newpath, "#{basen}b.#{dot}" )
puts "pass #{imgnm}"
next
end
img = Image.read(imgnm).first
resizedImg = ((originalSize[:columns] == img.columns) && (originalSize[:rows] == img.rows))? img : img.resize_to_fit(originalSize[:columns])
# 중간에 메모리 문제로 에러를 만나기 싫다면 destroy!로 사용한 객체를 파괴할 것!
# 오른쪽 날개
resizedImg.crop(x1, y, wd, hg).write( File.join( newpath, "#{basen}a.#{dot}" ) ).destroy!
# 왼쪽 날개
resizedImg.crop(x2, y, wd, hg).write( File.join( newpath, "#{basen}b.#{dot}" ) ).destroy!
puts "created #{newpath} : #{basen}a.#{dot} / #{basen}b.#{dot}"
img.destroy! ; resizedImg.destroy!
end
추가
나중에 보니 나와 비슷한 삽질을 하신 캐나다 분 peterhansen이 있었다.
이분은 Fred가 만든 스크립트 가운데 localthresh를 사용하였다.
보통 일반적인 방법을 사용하면 그림자가 지거나 어두운 곳의 글자는 식별하기 어렵게 나오는 경우가 많다. 색의 절대값을 이용기 때문이다. 그런데 이 방법은 이름과 변형 결과로 보았을 때 주변 색과 상대적인 비교를 통해 흑백을 나누는 것으로 보인다. 그래서인지 결과는 우수했다.
테스트를 해 보니 나에게는 대략 다음 정도가 적당했다.
localthresh -n yes -m 3 -r 35 -b 20 infile outfile
b는 20~25 사이가 적당했다. 작을 수록 어두워지고 클 수록 밝아지더라. 30이 넘으면 너무 밝아 의미가 없었다.
r은 25이 적당했다. 역시 클 수록 밝아졌다.
threshold라는 기법에 대한 여러가지 비교도 볼 수 있으니 참고하시기 바란다.
추가
아, 무척 쉬운 방법이 존재했다.
convert <input> -threshold xx% <output>
xx는 퍼센트(percent)로 0-100 사이이다. threshold
보다 작으면 흰색, 크면 검은색으로 만들어 준다.
예를 들어 50%로 잡으면 다음과 같이 된다. (-compress fax
는 용량 축소 )
이 방법의 장점은 pdf를 image로 변환하지 않고 그대로 바꿀 수 있다는 점이다.
convert <input> -threshold 50% -compress fax <output>
javascript로 변환한 것과 유사한 결과가 도출된다.
결과가 둔탁하지만 쓸모가 있다.
참고로 회색으로 만들려면 다음과 같이 하면 된다.
convert <input> -colorspace Gray <output>