OpenCVを使って点線を含めた縦棒を画像から削除する
以下のような画像から数字や横棒を残しつつ点線を削除したい。
方法はいくつか考えられるが、 一番最初に思いついたのはハフ変換を使って直線検出し、検出された線を画像から削除するという方法だった。
ただやってみると気づくのだが、検出された線の太さがわからないので、決め打ちの太さで線を打ち消すことになる。
上の画像みたいに太線と細線と点線が混じっている場合は、太い方を消すために、細い方の線に対しては本来線ではない部分も含めて削除することになる。 場合によっては隣接する文字を削ってしまい文字の判別が不可能になるかもしれない。
また、ハフ変換の場合は点線を直線として検出することができない場合がある。
よく考えると線が画像に対し水平垂直であるという前提条件がおけるのであれば、ハフ変換を用いるのは大仰である。 そこで、点線を含めて縦の線を削除するための簡単なアルゴリズムを作ったので紹介する。
アルゴリズムは、以下の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次元の配列だ。 このカーネルによって膨張を縦方向に限定することができ、線と隣接する文字との癒着を防ぐことができる。 これで、癒着を防ぎつつ、点線を実線に変更することが可能になった。
このテクニックを上記の画像に適用すると以下のような画像が得られる。(色がおかしいけど・・・) 見事に点線がつながっている。一方で横線と数字の下部と癒着しているが、これは後述の縦線検出するのに障害にならない。
投影(Vertical Projection) を使った直線が通るX軸の座標の求め方
ここまでで縦線は画像上にしっかり描画された状態になった。 あとは、直線を検出する上で太さを含めて検出したい。
「投影」とは、もともと画像上に横に並んでいる文字列を各文字をセグメンテーションするための方法である。
画像の同じX座標にあるピクセルを合計するというもので、np.sum()
の引数axisに1を指定すれば「投影」できる。
文字がある場所は合計値が大きくなり、文字間の空白はノイズが値が小さくなる。 散布図をかけば文字のところは山になり、文字間は谷になる。
これを縦線を含む画像に適用した場合は、縦線は切り立って山(スパイク)を発生させる。 実際に膨張・収縮を適用した画像に投影を適用すると以下のような散布図を得られる。
縦線を表すスパイクと文字を表す山が存在することがわかる。縦線と文字は高さで区別可能なので、しきい値でふるいにかけるとスパイクが発生しているX軸上の座標を求めれる。
実際に座標を求めて、そこに線を引いた結果は以下である。
赤線の太さがもとの縦線と同じであることが確認できる。
技術的な制約
このアルゴリズムの限界は以下である
- 直線が歪んでいる場合はうまく検出できない可能性がある
- 画像に対して縦線が完全に垂直でなければならない
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()
メカニカルキーボードのキートップを換装した
ARCHISSの87配列のメカニカルキーボードのキートップを換装した。キートップの表面が長年のタイピングによって研磨されツルツルになってカッコ悪いのと、シェル上の補完のためにタブキーを連打した結果キートップの根元が折れて外れるようになったためである。(代わりにCtrl+Iを使えばよいのだが)
世の中に流通してるメカニカルキーボードのスイッチはCHERRY社製であり、そのスイッチ向けに作られたキートップには互換性があるらしい。キーの幅も共通っぽい。
ということで、以下のキートップに換装してみた。87配列は104配列のテンキーレスバージョンぽい(実はよくわかってない)ので104配列向けのものから選んだ。
結果的には簡単な作業で、キーの高さなどもほぼ変わらず完璧な換装ができた。
換装前の状態。ツルピカ。左側のタブに前述の問題あり。
キートップ除去後の状態。赤軸のリニアで抜けるような打鍵感が好き。
届いたキートップ。台風15号の猛威が関東を襲う最中、福岡から札幌へ遥々やってきた。
こんな感じの小袋が数個入っていた。キートップは二種類の樹脂を重ねているタイプ。
換装後(トップの写真と同じ)。各種機能系のキーは薄めのグレーで、英数字記号のキーは濃いめのグレーとなっている。
全てのキーに書かれた文字が透けており、ARCHISSのキーボードではCapsLockとScreenLockが淡く青色に光る。ちなみに標準のキーキャップは2つのキーの下部に矩形の透過部が存在する。
換装し終えた後で気づいたのだが、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()
として表示される画像をみて確認はしておいた。
以上、何か問題など見つけたら教えてください。