OpenCVを使って点線を含めた縦棒を画像から削除する

以下のような画像から数字や横棒を残しつつ点線を削除したい。

f:id:KYudy:20191026131016j:plain

方法はいくつか考えられるが、 一番最初に思いついたのはハフ変換を使って直線検出し、検出された線を画像から削除するという方法だった。

ただやってみると気づくのだが、検出された線の太さがわからないので、決め打ちの太さで線を打ち消すことになる。

上の画像みたいに太線と細線と点線が混じっている場合は、太い方を消すために、細い方の線に対しては本来線ではない部分も含めて削除することになる。 場合によっては隣接する文字を削ってしまい文字の判別が不可能になるかもしれない。

また、ハフ変換の場合は点線を直線として検出することができない場合がある。

よく考えると線が画像に対し水平垂直であるという前提条件がおけるのであれば、ハフ変換を用いるのは大仰である。 そこで、点線を含めて縦の線を削除するための簡単なアルゴリズムを作ったので紹介する。

アルゴリズムは、以下の2つで成り立つ

  • 点線をつなぐための膨張・収縮テクニックの応用
  • 投影(Vertical Projection) を使った直線が通るX軸の座標の求め方

点線をつなぐための膨張・収縮テクニックの応用

膨張・収縮は有名なテクニックであり、それらを組み合わせるとノイズ除去、細線化等が実現される。 OpenCVではそれぞれcv2.dilate()cv2.erode()という関数として提供されている。 labs.eecs.tottori-u.ac.jp

当初は「クロージング」と同じ仕組みで点線をつなぐ事ができると考えた。 しかし点の間の距離が大きい(5pxとか?)場合には膨張の回数を大きめにしないと点同士がつながらず、 一方でその回数を増やしすぎると点線に隣接する文字が癒着してしまうという問題が発生することに気づいた。

これを回避する方法はcv2dilate()の第2引数であるkernelにあった。 具体的にはkernelに以下の様な配列を指定しただけだ。

[[0 0 1 0 0]
 [0 0 1 0 0]
 [0 0 1 0 0]
 [0 0 1 0 0]
 [0 0 1 0 0]]

ご覧いただくと分かる通り、真ん中の縦の列のみ1、ほかは0という2次元の配列だ。 このカーネルによって膨張を縦方向に限定することができ、線と隣接する文字との癒着を防ぐことができる。 これで、癒着を防ぎつつ、点線を実線に変更することが可能になった。

このテクニックを上記の画像に適用すると以下のような画像が得られる。(色がおかしいけど・・・) 見事に点線がつながっている。一方で横線と数字の下部と癒着しているが、これは後述の縦線検出するのに障害にならない。 f:id:KYudy:20191026133518p:plain

投影(Vertical Projection) を使った直線が通るX軸の座標の求め方

ここまでで縦線は画像上にしっかり描画された状態になった。 あとは、直線を検出する上で太さを含めて検出したい。

「投影」とは、もともと画像上に横に並んでいる文字列を各文字をセグメンテーションするための方法である。 画像の同じX座標にあるピクセルを合計するというもので、np.sum()の引数axisに1を指定すれば「投影」できる。

文字がある場所は合計値が大きくなり、文字間の空白はノイズが値が小さくなる。 散布図をかけば文字のところは山になり、文字間は谷になる。

これを縦線を含む画像に適用した場合は、縦線は切り立って山(スパイク)を発生させる。 実際に膨張・収縮を適用した画像に投影を適用すると以下のような散布図を得られる。 f:id:KYudy:20191026134600p:plain

縦線を表すスパイクと文字を表す山が存在することがわかる。縦線と文字は高さで区別可能なので、しきい値でふるいにかけるとスパイクが発生しているX軸上の座標を求めれる。

実際に座標を求めて、そこに線を引いた結果は以下である。 f:id:KYudy:20191026134855p:plain

赤線の太さがもとの縦線と同じであることが確認できる。

技術的な制約

このアルゴリズムの限界は以下である

  1. 直線が歪んでいる場合はうまく検出できない可能性がある
  2. 画像に対して縦線が完全に垂直でなければならない

2.に関しては縦線を画像に対して垂直にする方法はある。縦線が傾いている場合に比べて、垂直になっている場合はスパイクの高さが大きくなる。これを利用する方法である。簡単に実現できると思うが、今回は詳細を割愛する。

サンプルコード

最後にサンプルコードを載せる。

import cv2
import numpy as np
import matplotlib.pyplot as plt


img_path = "cell.jpg"

thresh_spike = 200

img_org = cv2.imread(img_path)
img = img_org.copy()

_, img_th = cv2.threshold(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 0, 255, cv2.THRESH_OTSU)

# reverse white and black for dilate and erode
img_th = 255 - img_th

# vertical kernel to connect dot line to solid line
kernel = np.zeros((5,5), np.uint8)
kernel[:, 2] = 1

img_th = cv2.dilate(img_th, kernel, iterations=8)
img_th = cv2.erode(img_th, kernel, iterations=2)

vp = np.sum((img_th != 0).astype(np.uint8), axis=0)
loc_x_spike = np.where(vp > thresh_spike)

# draw vertical lines
for x in loc_x_spike[0]:
    line_color = (255, 0, 0) # red
    cv2.line(img, (x, 0), (x, img.shape[0]), line_color, thickness=1)

_, (ax1, ax2, ax3, ax4) = plt.subplots(nrows=4)
ax1.imshow(img_org)
ax2.scatter(range(len(vp)), vp)
ax3.imshow(img_th)
ax4.imshow(img)
plt.show()

JetsonのラインナップとPyTorchが使えるかの調査

Jetsonを検討していたので、その内容をメモる。自分にしか役に立たないかもしれない。
PyTorchしか実装できないマンなので、PyTorchをインストールできるか、動作は問題ないかあたりをWebを漁りながら確認する。
 

ラインナップ

シリーズ
シリーズ内のラインナップ
CUDAコア数
Tensorコア数
値段
Nano
 
128
-
129USD
TX2 Series
TX2 4GB,  TX2, TX2i
256
-
249USD〜
AGX Xavier Series
Volta
Xavier 8GB,  AGX Xavier
384, 512
48, 64
599USD〜
Compare Modulesで更に詳細が出てくる。
電源電圧もそれぞれ異なるというのが注意ポイントだろう。
Wi-Fiが搭載されているのはTX2のみである。他はついていない。
TensorコアがAGXだけに搭載されているが、これはなんだろうか?(要調査)

PyTorchが動くのか?

結論からいうと比較的簡単に動きそう。
プロセッサがx86ではないのでPyTorch公式は扱っていないが、NVIDIAがビルド済みの状態で配布しているので問題なさそうである。
2019-10-04時点で最新のv1.2のビルド済みのパッケージが配布されているようだ。
 
NVIDIAの公式サイトでNanoへのPyTorchのインストール方法が紹介されているが、
冒頭でtorch2trtがアナウンスされている。
torch2trtはPyTorchのモデルをTensorRTのモデルに変換するためのツールらしい。
TensorRTはCUDAで開発され、INT8, FLOAT16向けに最適化することでモデルの推論速度を向上するためのものらしい。torch2trtのベンチマークを確認すると変換後はCNNの推論速度が2〜3倍程度に向上している。
推論速度の向上が必要になれば利用を検討するべきかもしれない。(個人的には30fpsを超えているようなモデルは最適化が不要な気もする)

メカニカルキーボードのキートップを換装した

f:id:KYudy:20190911220417j:image

ARCHISSの87配列のメカニカルキーボードのキートップを換装した。キートップの表面が長年のタイピングによって研磨されツルツルになってカッコ悪いのと、シェル上の補完のためにタブキーを連打した結果キートップの根元が折れて外れるようになったためである。(代わりにCtrl+Iを使えばよいのだが)

世の中に流通してるメカニカルキーボードのスイッチはCHERRY社製であり、そのスイッチ向けに作られたキートップには互換性があるらしい。キーの幅も共通っぽい。

ということで、以下のキートップに換装してみた。87配列は104配列のテンキーレスバージョンぽい(実はよくわかってない)ので104配列向けのものから選んだ。

 

結果的には簡単な作業で、キーの高さなどもほぼ変わらず完璧な換装ができた。

 

換装前の状態。ツルピカ。左側のタブに前述の問題あり。

f:id:KYudy:20190911212519j:image

キートップ除去後の状態。赤軸のリニアで抜けるような打鍵感が好き。

f:id:KYudy:20190911212521j:image

届いたキートップ台風15号の猛威が関東を襲う最中、福岡から札幌へ遥々やってきた。

f:id:KYudy:20190911214344j:image

こんな感じの小袋が数個入っていた。キートップは二種類の樹脂を重ねているタイプ。

f:id:KYudy:20190911212605j:image

換装後(トップの写真と同じ)。各種機能系のキーは薄めのグレーで、英数字記号のキーは濃いめのグレーとなっている。

f:id:KYudy:20190911220417j:image

全てのキーに書かれた文字が透けており、ARCHISSのキーボードではCapsLockとScreenLockが淡く青色に光る。ちなみに標準のキーキャップは2つのキーの下部に矩形の透過部が存在する。

f:id:KYudy:20190911232629j:image

換装し終えた後で気づいたのだが、ARCHISSのMaestroシリーズと雰囲気が似ている。派手すぎないが、個性をある程度主張する感じに仕上がった。

送料込みで2000円の費用がかかったが、また数年使えるのであればお得ではなかろうか。

 

Focal lossの実装(PyTorch)

Focal lossとは教師データに含まれるクラスごとのインスタンスが不均一であるときに学習がうまくいかないことを是正するために提案されたものだ。 One stageのObject detectionで背景クラスが大半を占めることで発生する問題に対して効果的に働くらしい。 仕組みがシンプルなので適用先はObject detectionには限らない汎用的な仕組みだといえる。

詳しいことは元の論文を読むなり、Qiitaを読むなりすることをお勧めするが、 この記事ではPyTorchでFocal lossを使用するために私が行った修正等について説明したい。

PyTorchのコミュニティでFocal lossについて議論されており、以下がおすすめされていたので使ってみたが、途中でone_hotを作って計算するところがGPUに載せ替えないと動かないのが不満になり、そこだけ直した。(Skorchを使っているとlossの計算時に to(device) とかやりづらいので。) github.com

直した結果は以下である。修正前の実装をFocalLossWithOneHot とし、修正後の実装をFocalLossWithoutOneHotとしている。 main()to("cuda")してFocalLossWithOneHot はエラーを吐き、FocalLossWithoutOneHotはエラーを吐かないこと確認した。 gist.github.com

np.isin()について

概要

np.where()を使って特定の条件を満たす項目を抜き出す処理は良く行う。 今回はその発展として、複数の値に一致する項目を抜き出してきたいという状況を考える。 そのような状況では、np.isin()を使えばよいという話。

詳細

具体的には以下のようなフルーツ( fruits )と食べたいものリスト( i_want_to_eat )があるとして、自分の食べたいものに該当するインデックスを知りたいとする。

>>> fruits = ['apple', 'ringo', 'banana', 'mikan']
>>> i_want_to_eat = ['ringo', 'mikan']

np.isin()を使うと以下のようにfruitに入っている複数のフルーツの内i_want_to_eatに入っているフルーツに該当する部分がTrue、それ以外にFalseが入った配列が返ってくる。

>>> fruits = np.array(fruits)
>>> i_want_to_eat = ['ringo', 'mikan']
>>> np.isin(fruits, i_want_to_eat)
array([False,  True, False,  True])

もし配列からそのフルーツのみ抜き出したければ、普通にnumpyのブールインデックス参照を行えばよい

>>> fruits[np.isin(fruits, i_want_to_eat)]
array(['ringo', 'mikan'], dtype='<U6')

以上。 普通にマニュアルに書いてあるけど、ちょっと調べてなかなかたどり着けなかったので、メモ代わりに書いた。

画像処理にエンジニア検定のエキスパートに合格した

概要

画像処理にエンジニア検定はCG-ARTS協会が行っている検定の一つ。

自分が受けた2018年後期の検定の合格率は40.4%だった。 https://www.cgarts.or.jp/kentei/result/passing.html

真面目に勉強していればまぁまぁ受かるレベルである。

得られたもの

当然ながら資格が得られたわけだが、それは目的にはしていなかった。

自分は最近、仕事で深層学習による画像処理を行っている。 深層学習がいかに便利で有用であっても画像処理を知らずに仕事にはならないわけだが、 画像処理を始めたころは、Webを調べて出てくる断片的な情報だけでその場をしのいでいた。

そこで、流石にまずいということで体系的な勉強の必要性に迫られて、この検定にたどりついた。

結果的にはこの検定の勉強に必要な教本は非常に役に立った。 「ディジタル画像処理」という本なのだが、画像に触るなら検定を受けなくてもいいから、この本は読むべきである。

内容が網羅的、かつ体系的にまとまっている。

ちなみに検定自体は、それほど難しい問題は出ないが、上記の本には数式がちらほら出てくる。 とりあえず、サラッと意味するところを捉える程度で良いと思う。 実務で使うときに真面目に読みこめば良いのではないか。

MNISTとFashion-MNISTのPyTorchのデータローダ

概要

MNISTとFashion-MINISTを組み合わせて学習してみたかったが、既存のデータセットだと多分無理っぽかったので、自作してみた。(車輪のなんとやらではないという言い訳)

ここでは組み合わせるところまでは書かず、一般的なMNISTのローダをどのように書けばよいかが分かるようなものを記載したいと思う。

もし同じような動機に駆られたら参考にしてほしい。

ちなみに、単純にMNISTをPytorchで読み込むだけだったら、以下でいける。とても簡単。

train = torch.utils.data.DataLoader(MNIST('data', train=False))

準備

MNISTおよびFashion-MNISTのバイナリデータ(gzipで圧縮済み)をダウンロードしてきて、 適当なディレクトリに入れておく。 自分の場合は/share/datasets/mnist/data/の下に入れておいた。

MNISTは以下のページにダウンロード元が記載されている。

http://yann.lecun.com/exdb/mnist/

Fashion-MNISTは以下のページにダウロード元が記載されている。

https://github.com/zalandoresearch/fashion-mnist

データの中身

データの中身はバイナリになっている。

ラベルは1バイトにつき、1つのラベル(0-10)が入っている。(たしか)

画像の場合は1バイトが1ピクセルに対応して、0-255までの値が入っている。(多分)

ソースコードと解説

import torch
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
import gzip
import os
import numpy as np
from PIL import Image


class MNIST(Dataset):
    def __init__(self, kind="MNIST", train=True):
        assert(kind in ["MNIST", "Fashion"])
        self.train = train
        if kind == "MNIST":
            self.data_dir="/share/datasets/mnist/data/mnist/"
        else:
            self.data_dir="/share/datasets/mnist/data/fashion/"

        if self.train:
            self.files = {
                "images": "train-images-idx3-ubyte.gz",
                "labels": "train-labels-idx1-ubyte.gz"
            }
        else:
            self.files = {
                "images": "t10k-images-idx3-ubyte.gz",
                "labels": "t10k-labels-idx1-ubyte.gz"
            }

        self.init_images_labels()

        self.augmentor = transforms.Compose([transforms.RandomHorizontalFlip(),
                                             transforms.RandomRotation(degrees=30),
                                             transforms.RandomVerticalFlip()])
        self.loader = transforms.Compose([transforms.ToTensor()])


    def __len__(self):
        return len(self.X)

    def __getitem__(self, idx):
        image = self.X[idx]
        label = self.y[idx]

        image = self._load_image(image)

        label = np.expand_dims(label, axis=0)
        label = torch.from_numpy(np.array(label))

        return image, label

    def _load_image(self, image):
        image = Image.fromarray(image)
        # Gray scale to RGB
        # image = image.convert('RGB')
        if self.train:
            image = self.augmentor(image)
        image = self.loader(image).float()
        return image

    def init_images_labels(self):
        self.y = self._read_labels_from_binary(os.path.join(self.data_dir, self.files["labels"]))
        self.X = self._read_images_from_binary(os.path.join(self.data_dir, self.files["images"]))

    def _read_labels_from_binary(self, filepath):
        with gzip.open(filepath, mode="rb") as f:
            data = f.read()
            array = np.fromstring(data, dtype='<u1')
            # first 8 bytes are metadata
            array = array[8:]

        return array

    def _read_images_from_binary(self, filepath):
        with gzip.open(filepath, mode="rb") as f:
            data = f.read()
            array = np.fromstring(data, dtype='<u1')
            # first 16 bytes are metadata
            array = array[16:]
            len_array = len(array) // 28 // 28
            array = array.reshape((len_array, 28, 28))

        return array

def main():
    mnist = MNIST(kind="Fashion")

if __name__ == "__main__":
    main()

多くはPyTorchに共通する処理なので、説明を割愛し、MNISTに特化した部分について説明する。

init

MNISTとFashion-MNISTはひとつのデータは同じ大きさになっているし、データの数も全く一緒である。 したがってファイルパスの設定を変えれば、MNISTとFashion-MNISTをスイッチできる。 それが冒頭の__init__()の中で定義されたkindの役割である。

_read_labels_from_binary

ラベルをバイナリから読み取るために、ファイルをgzipライブラリで解凍し、numpyのfromstringで配列にする。 読み込み元はバイナリなのだが、まぁそれは置いておいて、<u1で1バイトごとに符号なし整数として読み込んでいる。 冒頭の8バイトはこのデータ全体の説明になっている。詳しくは MNISTの公式ページにある「TRAINING SET LABEL FILE (train-labels-idx1-ubyte)」を読むこと。 データ数等が書いてあるが、正直要らないので、読み捨てにする。

_read_images_from_binary

ラベルとほぼ同じ処理を行ったうえで、冒頭の16バイトをラベルと同じ理由で捨てる。 そしてひとつのデータは28x28の長さの1次元のベクトルなのだが、後で画像として扱うためにreshapeして2次元のベクトルに直している。 reshapeの処理が不安だったので、書いている途中でPIL.Image.fromarray(image).show()として表示される画像をみて確認はしておいた。

以上、何か問題など見つけたら教えてください。