テキスト画像の文字の背景を白飛びさせて読みやすくする

スマホで書類を撮影して使うことが多くなった。手軽だけどスキャナで取り込んだものより鮮明さに欠ける。スマホで撮影したようなコントラストの鈍い画像を読みやすくするには、文字を黒く残したまま背景の明度を上げるのがいいだろう。背景を白く飛ばす処理の都合で、画像の文字部分とただの白地部分を区別する必要がでてきたが、画像の局所ごとの画素の統計をとり画素値の標準偏差の大小で判定するとうまくいった。

例として次の画像を加工して行くことにする。各画像をクリックすると拡大して見ることができるはず。
![tarama36p.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/4210c839-e67c-2be3-dd7f-20aa172fbe77.jpeg)
波田野 直樹 著「多良間島幻視行」p.36

#ヒストグラム平坦化
画像の鮮明化をするときによく行われるのはヒストグラムの平坦化処理である。画像の画素の明度が狭い範囲に収まっているとき、それを画像フォーマットのレンジ全体、グレースケール画像なら0～255の範囲に広げてやれば、画素同士の違いが大きくなり画像がはっきりする。OpenCVには専用の関数が用意されており、以下のリンク先に詳しい説明がある。
[OpenCVでのヒストグラム>ヒストグラム その2: ヒストグラム平坦化](http://labs.eecs.tottori-u.ac.jp/sd/Member/oyamada/OpenCV/html/py_tutorials/py_imgproc/py_histograms/py_histogram_equalization/py_histogram_equalization.html)
これを利用して元画像をグレースケール化してからヒストグラム平坦化したのが以下の画像である。プログラムとともに示す。

```Python
 bookimg = cv2.imread('tarama36p.jpg')
 img_gray = cv2.cvtColor(bookimg, cv2.COLOR_BGR2GRAY)
 
 equ = cv2.equalizeHist(img_gray)
 cv2.imwrite('tarama36pcv2.jpg', equ )
```
![tarama36pcv2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/cab9fafc-a6e3-28a5-2a39-a67f8effa333.jpeg)
結果は特に文字が鮮明になった感じはしない。元画像では分からなかったが、左より右ページの方が明るいようだ。上部の金属片の反射が強調されている。実はこの画像のヒストグラムは以下のようになっていて、赤線が画素の明るさの最大最小値を表しているが、既に最大最小値が画像レンジいっぱいまで広がっているので、単純なヒストグラム平坦化の効果は少ない。
![tarama36p_hist_maxmin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/277e1016-5c5a-b133-27fb-ffbceed5aa31.png)
OpenCVには適用的ヒストグラム平坦化という、画像を細かいブロックに分けブロックごとにヒストグラム平坦化するという関数も用意されている。それによる処理結果が以下の画像である。

```Python
 bookimg = cv2.imread('tarama36p.jpg')
 img_gray = cv2.cvtColor(bookimg, cv2.COLOR_BGR2GRAY)
 
 equ = cv2.equalizeHist(img_gray)
 cv2.imwrite('tarama36pcv2.jpg', equ )
```
![tarama36pcv2cl1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/92e6d77c-f8a1-ba04-c6e3-1cd3eceab4ec.jpeg)
cv2.equalizeHist()よりは見やすくなったがスキャナほどではない。
#白画素をすべて真っ白に
汎用のコントラスト強化処理は、白い部分の画素値の違いもある程度残そうとする。テキスト画像の場合、背景白地の細かな情報は不要なので、ある閾値より上の画素はは全部真っ白、画素値としてすべて255に書き換えてもいいだろう。黒い方は文字形状の情報が入っているので、元の画素値に1より小さい値をかけて、値としては黒い方に寄せるが元の傾向は残すようにした。閾値はヒストグラムの中央値あたりの値から、仮に140としてみた。プログラムと処理結果は以下のとおり。

```Python
    for y in range(img_gray.shape[0]):
         for x in range(img_gray.shape[1]):
             if img_gray[y][x] > 140:
                 img_gray[y][x] = 255
             else:
                 img_gray[y][x] = img_gray[y][x] * 0.5

    cv2.imwrite('tarama36p140.jpg', img_gray )
```
![tarama36p140.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/be96d083-a42a-1ea1-d877-11b871fb4296.jpeg)
右側は狙い通り背景が白くつぶれ文字がクッキリとした。金属片の余計な映り込みも消えている。しかしながら左下は全体として暗めだったので、背景部分まで黒と認識されて強調されてしまった。かといって左下に閾値を合わせると、今度は右側で文字部分が白く飛んでしまう。つまり適切な閾値は画像の場所によって違う。
#画像をブロックに分けて処理する
適用的ヒストグラム平坦化同様、元画像を縦横64ドットのブロックに分け、それぞれ適切な閾値を求め処理する。閾値をどう決めるかが重要だが、ここでは閾値の値は各ブロックの画素の中央値の下半分のさらに中央値、つまりブロック画素全体の1/4を黒とみなすような値とした。ブロック画像imgを引数として閾値を返す関数をPythonで書くと、以下のようになる。使ってみると大雑把だがまあまあ適切な値を返してくれるようだ。ただし黒字に白のテキストだとうまくいかないだろう。

```Python
 import numpy as np
 def getBWThrsh(img):
     med = np.median(img)
     fild = img[img < med]
     return np.median(fild) 
```
処理した結果が以下の画像である。なお、各ブロックごとにヒストグラム平均化処理も合わせて行い、白く飛ばすところは単純に255を代入するのではなく、元の画素に大多数の背景が256を越えるような係数をかけて代入している。ほとんどは白く飛ばすが、それよりちょっと暗い文字の部分は残すためだ。
![tarama36p_s0.00b64.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/7df40b05-45f5-569f-6c42-0876492674db.jpeg)
文字部分はうまく背景が白く抜けているが、改行して文字のないただの白地の部分は、薄っすらと裏のページの文字が透けている。拡大したのが以下の画像である。
![a3bcf44c70f2e1a919809ebccd290c54-png.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/0092d6a7-6b46-976c-1381-676905934a7b.png)
裏ページが透けた部分には非常に微かな濃淡の差しかないが、ヒストグラム平均化処理をすることで、見事に裏文字が浮き上がってきたわけである。
#文字と白地の区別をつける
裏ページが透けるので白地部分はヒストグラム平均化処理をしないようにしたい。文字部分と白地部分を見分けるにはどうすればいいだろうか。これまでブロックごとにnumpyで統計処理を行い画素の中央値などを求めてきたが、文字と白地の区別に画素値の標準偏差が使えるのではないかと思いついた。白地は画素値のバラツキが少なく標準偏差が小さい、文字部分はそれより大きい値になるだろう。ともかく各ブロックの画素の標準偏差を求め、どういう値が多いのかヒストグラムを作成して傾向を調べてみた。
![tarama36p_stdhist.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/8529cd16-4d92-1ba5-ca43-3888d60f5313.png)
左側の小さい値に突出してピークになっているところがあるが、これか白地のブロックだろう。このピークを含むような値に標準偏差の閾値を設定すれば白地を判定できる。閾値をあまり小さくすると白地のゴミが残るし、大きくすると白地に小さい文字の一部が含まれていても白地とみなされて欠けてしまうので、適切な閾値を設定するのは実は難しいのだが、ともかくこれで文字と白地の区別をつけ、白地は完全に白く飛ばす処理を行ったのが以下の画像である。
![tarama36p_s6.00b64.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/114010/1f1419d7-26e6-1f84-a7d4-01f3bc191014.jpeg)
周辺のテキストでないところに、いろいろとゴミが残ったが、テキスト部分は綺麗に処理できたと思う。ブロック単位で白地とそうでない部分の区別がつくようになったので、白地ブロックが連続していると周りの余白部分であるとか、余白部にぽつりと文字が入ってるとノンブルだろうとか、いろいろな判定に応用ができるような気がする。

以上のソースは以下のとおり。一番下の行にあるsharpenImg()の引数に変換したいファイル名を書くと背景を白飛びさせたファイルを作成する。~~今のところ変換に数十秒の時間がかかるが、Cなどで書き換えれば実用な処理速度になると思う。~~
(画像のトーンカーブの処理をPythonで記述したので実行に数十秒かかっていたが、cv2.LUT関数を使うと劇的に速くなった。Pythonの処理は、なるべくライブラリ内で済ませた方がいいね。2021/12/14)

```Python
import cv2
from matplotlib import pyplot as plt
import numpy as np

def getStdThrsh(img, Blocksize):
    stds = []
    for y in range( 0, img.shape[0], Blocksize ):
        for x in range( 0, img.shape[1], Blocksize ):
            pimg = img[y:y+Blocksize, x:x+Blocksize]
            std = np.std( pimg )
            stds.append(std)

    hist = np.histogram( stds, bins=64 )
    peaki = np.argmax(hist[0])   

    #plt.hist( stds, bins=64 )
    #plt.show()

    slim = 6.0
    for n in range(peaki,len(hist[0])-1):
        if hist[0][n] < hist[0][n+1]:
            slim = hist[1][n+1]
            break

    if slim > 6.0:
        slim = 6.0

    return slim

def getOutputName( title, slim ):
    return title + "_s{:04.2f}.jpg".format( slim )

def sharpenImg(imgfile):
    Testimagefile = imgfile
    TestimageTitle = Testimagefile.split('.')[0]
    Blocksize = 64

    bookimg = cv2.imread( Testimagefile )
    img_gray = cv2.cvtColor(bookimg, cv2.COLOR_BGR2GRAY)

    slim = getStdThrsh(img_gray, Blocksize)
    yimgs=[]
    for y in range( 0, img_gray.shape[0], Blocksize ):
        s = ""
        ximgs=[]
        for x in range( 0, img_gray.shape[1], Blocksize ):
            pimg = img_gray[y:y+Blocksize, x:x+Blocksize]
            std = np.std( pimg )

            if std < slim:
                s = s + "B"
                ximg=np.zeros(pimg.shape) + 255
            else:
                s = s + "_"
                lut = np.zeros(256)
                white = int(np.median(pimg))
                black = int(white / 2)
                cnt = int(white - black)
                for n in range(cnt):
                    lut[black+n]=( int(256 * n / cnt) )
                for n in range(white,256):
                    lut[n]=(255)
                ximg=cv2.LUT(pimg,lut)
            ximgs.append(ximg)
        print( "{:4d} {:s}".format( y, s ) )
        yimgs.append( cv2.hconcat( ximgs ) )

    outimage = cv2.vconcat(yimgs)
    cv2.imwrite(getOutputName(TestimageTitle, slim), outimage )

if __name__ =='__main__':
    sharpenImg('tarama36p.jpg')

"""
以下は以前公開したソースで実行に数十秒かかっていた。
import cv2
from matplotlib import pyplot as plt
import numpy as np

def getStdThrsh(img, Blocksize):
    stds = []
    for y in range( 0, img.shape[0], Blocksize ):
        for x in range( 0, img.shape[1], Blocksize ):
            pimg = img[y:y+Blocksize, x:x+Blocksize]
            std = np.std( pimg )
            minv = np.min( pimg )
            maxv = np.max( pimg )
            stds.append(std)

    hist = np.histogram( stds, bins=64 )
    peaki = np.argmax(hist[0])   

    #plt.hist( stds, bins=64 )
    #plt.show()

    slim = 6.0
    for n in range(peaki,len(hist[0])-1):
        if hist[0][n] < hist[0][n+1]:
            slim = hist[1][n+1]
            break

    if slim > 6.0:
        slim = 6.0
    
    return slim

def getBWThrsh(img):
    med = np.median(img)
    fild = img[img < med]
    return np.median(fild)

def getWbias( img, bwthr ):
    wimg = img[ img > bwthr ]
    hist = np.histogram( wimg, bins=16 )
    agm = np.argmax(hist[0])
    return hist[1][agm]

def getOutputName( title, slim ):
    return title + "_s{:04.2f}.jpg".format( slim )

def sharpenImg(imgfile):
    Testimagefile = imgfile
    TestimageTitle = Testimagefile.split('.')[0]
    Blocksize = 64
    Bbias = 0.2

    bookimg = cv2.imread( Testimagefile )
    img_gray = cv2.cvtColor(bookimg, cv2.COLOR_BGR2GRAY)
    outimage = img_gray.copy()

    slim = getStdThrsh(img_gray, Blocksize)
    for y in range( 0, img_gray.shape[0], Blocksize ):
        s = ""
        for x in range( 0, img_gray.shape[1], Blocksize ):
            pimg = img_gray[y:y+Blocksize, x:x+Blocksize]
            std = np.std( pimg )
            minv = np.min( pimg )
            maxv = np.max( pimg )
            pimg -= minv

            cimg = pimg.copy()
            if maxv != minv:
                for sy in range (cimg.shape[0]):
                    for sx in range( cimg.shape[1] ):
                        cimg[sy][sx] = (cimg[sy][sx]*255.0)/(maxv - minv)

            bwthrsh = getBWThrsh( pimg )
            wb = getWbias( cimg, bwthrsh )
            if wb == 0:
                wbias = 1.5
            else:
                wbias = 256 / wb
            
            if std < slim:
                s = s + "B"
                for sy in range (pimg.shape[0]):
                    for sx in range( pimg.shape[1] ):
                        outimage[y+sy][x+sx] = 255
            else:
                s = s + "_"
                for sy in range (cimg.shape[0]):
                    for sx in range( cimg.shape[1] ):
                        if cimg[sy][sx] > bwthrsh:
                            v = cimg[sy][sx]
                            v = v * wbias
                            if v > 255:
                                v = 255
                            outimage[y+sy][x+sx] = v
                        else:
                            outimage[y+sy][x+sx] = cimg[sy][sx] * Bbias
        print( "{:4d} {:s}".format( y, s ) )

    cv2.imwrite(getOutputName(TestimageTitle, slim), outimage )

if __name__ =='__main__':
    sharpenImg('tarama36p.jpg')
"""
```

https://github.com/pie-xx/TextImageViewer