四畳半テクノポリス

コロナのストレスで気が狂い、D進した院生

2値のHLAC特徴量で明朝体とゴシック体を分類してみる。

動機

HLAC特長量って最近よくTwitterで見かけます。この特長量がどうもすごいということらしいので、KNNのような単純な機械学習アルゴリズムであってもある程度の精度が実現可能なのではと思い、KNNでMNISTが分類できるか挑戦してみました。

HLACとは

HLAC特徴量とは人間の手でデザインされた畳み込みフィルタで畳み込みを行い、それぞれのフィルタに一致するパターンがどれだけ存在したかのヒストグラムを計算するアルゴリズムです。

一層目が手作りのカーネルを使ったカーネル数Nの畳み込み演算で、2層目が出力Nでそれぞれのカーネルの出力だけ選択的に1で選択された全結合層となっている畳み込みニューラルネットワークだと私は理解しています。

HLACは以下のような面白い特長を持っています。

  1. 位置の普遍性:画像中のパターンの位置が変わっても特徴量に影響を与えない
  2. 加法性:パターンAとパターンBが画像中にある場合、画像全体の特徴量はパターンAの特徴量とパターンBの特徴量の和になる。
  3. 適応学習性:特徴量が固定であるので、Deepの様に毎回学習せずとも対応できる。

この辺の説明は門外漢の私よりの説明よりアダコテックのホームページを見たほうが良いと思います。

zenn.dev

主な用途としては異常検知や、不審な動きを検出するのが得意なようです。動画用のCHLACという特徴量を使うことで監視カメラ中でピッキングしている人を検出する論文などありました。 また、DNNと比較すると、圧倒的に軽量です。マイコンでも動きます。まあ、この辺は後段の分類器の速度にも依るでしょう。

実験

HLACというアルゴリズムが凄いらしいので、HLACならユーグリッド距離のKNNみたいな単純なアルゴリズムでもいい感じに推論できるのでは?ということでHLACとKNNを使ってMNISTの学習を行ってみることにしました

from keras.datasets import mnist
import matplotlib.pyplot as plt
# mnistデータのダウンロード
(X_train, y_train), (X_test, y_test) = mnist.load_data()
print("学習データのラベル:", y_train[0])
X_train = X_train
y_train = y_train
X_train[X_train<128] = False
X_train[X_train>=128] = True
plt.imshow(X_train[0].reshape(28, 28), cmap='Greys')
plt.show()
print("テストデータのラベル:", y_test[0])
X_test[X_test<128] = False
X_test[X_test>=128] = True
plt.imshow(X_test[0].reshape(28, 28), cmap='Greys')
plt.show()

公開されていた関数を拝借します。

import numpy as np
hlac_filters =  [np.array([[False, False, False], [False,  True, False], [False, False, False]]),  np.array([[False, False, False], [False,  True,  True], [False, False, False]]),  np.array([[False, False,  True], [False,  True, False], [False, False, False]]),  np.array([[False,  True, False], [False,  True, False], [False, False, False]]),  np.array([[ True, False, False], [False,  True, False], [False, False, False]]),  np.array([[False, False, False], [ True,  True,  True], [False, False, False]]),  np.array([[False, False,  True], [False,  True, False], [ True, False, False]]),  np.array([[False,  True, False], [False,  True, False], [False,  True, False]]),  np.array([[ True, False, False], [False,  True, False], [False, False,  True]]),  np.array([[False, False,  True], [ True,  True, False], [False, False, False]]),  np.array([[False,  True, False], [False,  True, False], [ True, False, False]]),  np.array([[ True, False, False], [False,  True, False], [False,  True, False]]),  np.array([[False, False, False], [ True,  True, False], [False, False,  True]]),  np.array([[False, False, False], [False,  True,  True], [ True, False, False]]),  np.array([[False, False,  True], [False,  True, False], [False,  True, False]]),  np.array([[False,  True, False], [False,  True, False], [False, False,  True]]),  np.array([[ True, False, False], [False,  True,  True], [False, False, False]]),  np.array([[False,  True, False], [ True,  True, False], [False, False, False]]),  np.array([[ True, False, False], [False,  True, False], [ True, False, False]]),  np.array([[False, False, False], [ True,  True, False], [False,  True, False]]),  np.array([[False, False, False], [False,  True, False], [ True, False,  True]]),  np.array([[False, False, False], [False,  True,  True], [False,  True, False]]),  np.array([[False, False,  True], [False,  True, False], [False, False,  True]]),  np.array([[False,  True, False], [False,  True,  True], [False, False, False]]),  np.array([[ True, False,  True], [False,  True, False], [False, False, False]])]

from scipy import signal
import numpy
def extract_hlac(image, hlac_filters):
    result = []
    image = np.uint8(image)
    hlac_filters = np.uint8(hlac_filters)
    for filter in hlac_filters:
        feature_map = signal.convolve2d(image, filter, mode='valid')
        count = np.sum(feature_map == np.sum(filter)) # マスクと一致する数を集計
        result.append(count)
    #print(result)
    return np.array(result)

特長量抽出します。

#データそのままでの推論
from sklearn.neighbors import KNeighborsClassifier
HLAC_knn = KNeighborsClassifier(n_neighbors = 10)
# 学習データをフィット
HLAC_knn.fit(hlac_train, y_train)
# 予測実行
pred_y = HLAC_knn.predict(hlac_test)
print((pred_y == y_test).sum()/len(pred_y)

#データそのままでの推論
knn = KNeighborsClassifier(n_neighbors = 10)
print(X_train.shape)
X_train = X_train.reshape((1000,28*28))
# 学習データをフィット
knn.fit(X_train, y_train)
# 予測実行
X_test = X_test.reshape((10000,28*28))
pred_y = knn.predict(X_test)

全然うまくいかないですね。学習データを用いてK=1で推論しても100%にならないので、HLACを適応すること自体が根本的にまちがっているようです。

そもそも真ん中にみんな書いてくれてるMNISTだとHLACの特徴である位置の普遍性を殺してますね。 というかMNISTが単純なKNNでもかなり精度が出ることに驚きました。こういうの得意なんだな。

敗因

敗因は以下の通りだと予想しました。

  • HLACの利点である、位置の普遍性や加法性が意味を持たないタスクである。

  • 人によって個人差のあるごちゃごちゃしたデータは局所的な形状の相関は意味を持たなそうなので、得意ではない。

  • 文字は線画であり、形状ではなく、連続性やトポロジが意味を持っているのでHLACに向かない

  • データサイズが小さすぎて別画像なのに特徴量が重複している。

結果としては「蟹スプーンで牛を捌こうとしたけどできませんでした」的なオチになってしまいました。 このままだと、HLACに悪いイメージを植え付けただけになりそうなので他のタスクも考えてみます。

明朝体とゴシックの分類

MNISTでは盛大に失敗しましたが、その反省を元に明朝体とゴシックの分類に挑戦してみることにしました。明朝体はとめ、やハネなど、先の末端に「ヒゲ飾り」「うろこ」などと呼ばれる、筆の跡が再現されているのに対して、ゴシックにはそういったものがありません。ということは、装飾にHLACの各フィルタが反応すると思うので検出しやすいのではないかと予想しました。

実験

IPAの明朝とゴシックで2100文字程度の常用漢字を描画し、HLAC特徴量を抽出したのち、明朝体なのか、ゴシック体なのかXGBoostで分類しました。Deepよりよっぽど軽くなっていると思います。

IPAexフォントおよびIPAフォントについて | 一般社団法人 文字情報技術促進協議会

常用漢字はそれぞれのフォントで画像中に描画されたあと、順番をシャッフルされ、先頭1000個を学習データ、1200以降を推論データとしました。200個はLossを確認するための開発データです。全体の学習データ数は4200文字位になります。

評価として、学習データのうち、N*50 個を実際にXGBoostの学習データとして、Nを1づつ増やして、どれだけ少ないデータで学習できるかに挑戦しました。

from PIL import Image, ImageFont, ImageDraw
import cv2
import numpy as np
import matplotlib.pyplot as plt
import numpy as np

# 画像に文字を入れる関数
Mincho_font_path = "/path/to/font/ipam.ttf"          
Mincho_font_size = 64              
Mincho_font = ImageFont.truetype(Mincho_font_path, Mincho_font_size)                           

Gothic_font_path = "/path/to/font/ipaexg.ttf"          
Gothic_font_size = 64              
Gothic_font = ImageFont.truetype(Gothic_font_path, Gothic_font_size)                           
                      

#2値のHLAC特徴量の抽出器
hlac_filters =  [np.array([[False, False, False], [False,  True, False], [False, False, False]]),  np.array([[False, False, False], [False,  True,  True], [False, False, False]]),  np.array([[False, False,  True], [False,  True, False], [False, False, False]]),  np.array([[False,  True, False], [False,  True, False], [False, False, False]]),  np.array([[ True, False, False], [False,  True, False], [False, False, False]]),  np.array([[False, False, False], [ True,  True,  True], [False, False, False]]),  np.array([[False, False,  True], [False,  True, False], [ True, False, False]]),  np.array([[False,  True, False], [False,  True, False], [False,  True, False]]),  np.array([[ True, False, False], [False,  True, False], [False, False,  True]]),  np.array([[False, False,  True], [ True,  True, False], [False, False, False]]),  np.array([[False,  True, False], [False,  True, False], [ True, False, False]]),  np.array([[ True, False, False], [False,  True, False], [False,  True, False]]),  np.array([[False, False, False], [ True,  True, False], [False, False,  True]]),  np.array([[False, False, False], [False,  True,  True], [ True, False, False]]),  np.array([[False, False,  True], [False,  True, False], [False,  True, False]]),  np.array([[False,  True, False], [False,  True, False], [False, False,  True]]),  np.array([[ True, False, False], [False,  True,  True], [False, False, False]]),  np.array([[False,  True, False], [ True,  True, False], [False, False, False]]),  np.array([[ True, False, False], [False,  True, False], [ True, False, False]]),  np.array([[False, False, False], [ True,  True, False], [False,  True, False]]),  np.array([[False, False, False], [False,  True, False], [ True, False,  True]]),  np.array([[False, False, False], [False,  True,  True], [False,  True, False]]),  np.array([[False, False,  True], [False,  True, False], [False, False,  True]]),  np.array([[False,  True, False], [False,  True,  True], [False, False, False]]),  np.array([[ True, False,  True], [False,  True, False], [False, False, False]])]

from scipy import signal
import numpy
def extract_hlac(image, hlac_filters):
    result = []
    image = np.uint8(image)
    hlac_filters = np.uint8(hlac_filters)
    for filter in hlac_filters:
        feature_map = signal.convolve2d(image, filter, mode='valid')
        count = np.sum(feature_map == np.sum(filter)) # マスクと一致する数を集計
        result.append(count)
    #print(result)
    return np.array(result)



Mincho_images = []
Gothic_images = []

for kanji in zyouyou:
  size=(64,64)
  black_img=np.zeros(size,np.uint8)
  white_img=black_img+255
  img = Image.fromarray(white_img)                          
  draw = ImageDraw.Draw(img)   
  draw.text((0, 0), kanji, font=Mincho_font, fill=(0))
  Mincho_images.append(np.array(img) > 128)

  size=(64,64)
  black_img=np.zeros(size,np.uint8)
  white_img=black_img+255
  img = Image.fromarray(white_img)                          
  draw = ImageDraw.Draw(img)   
  draw.text((0, 0), kanji, font=Gothic_font, fill=(0))
  Gothic_images.append(np.array(img) > 128)


Mincho = Mincho_images
Mincho_labels = np.zeros(len(Mincho_images))
Gothic = Gothic_images
Gothick_labels = np.ones(len(Gothic_images))

#常用漢字一覧
zyouyou = "亜哀挨愛曖悪握圧扱宛嵐安案暗以衣位囲医依委威為畏胃尉異移萎偉椅彙意違維慰遺緯域育一壱逸茨芋引印因咽姻員院淫陰飲隠韻右宇羽雨唄鬱畝浦運雲永泳英映栄営詠影鋭衛易疫益液駅悦越謁閲円延沿炎怨宴媛援園煙猿遠鉛塩演縁艶汚王凹央応往押旺欧殴桜翁奥横岡屋億憶臆虞乙俺卸音恩温穏下化火加可仮何花佳価果河苛科架夏家荷華菓貨渦過嫁暇禍靴寡歌箇稼課蚊牙瓦我画芽賀雅餓介回灰会快戒改怪拐悔海界皆械絵開階塊楷解潰壊懐諧貝外劾害崖涯街慨蓋該概骸垣柿各角拡革格核殻郭覚較隔閣確獲嚇穫学岳楽額顎掛潟括活喝渇割葛滑褐轄且株釜鎌刈干刊甘汗缶完肝官冠巻看陥乾勘患貫寒喚堪換敢棺款間閑勧寛幹感漢慣管関歓監緩憾還館環簡観韓艦鑑丸含岸岩玩眼頑顔願企伎危机気岐希忌汽奇祈季紀軌既記起飢鬼帰基寄規亀喜幾揮期棋貴棄毀旗器畿輝機騎技宜偽欺義疑儀戯擬犠議菊吉喫詰却客脚逆虐九久及弓丘旧休吸朽臼求究泣急級糾宮救球給嗅窮牛去巨居拒拠挙虚許距魚御漁凶共叫狂京享供協況峡挟狭恐恭胸脅強教郷境橋矯鏡競響驚仰暁業凝曲局極玉巾斤均近金菌勤琴筋僅禁緊錦謹襟吟銀区句苦駆具惧愚空偶遇隅串屈掘窟熊繰君訓勲薫軍郡群兄刑形系径茎係型契計恵啓掲渓経蛍敬景軽傾携継詣慶憬稽憩警鶏芸迎鯨隙劇撃激桁欠穴血決結傑潔月犬件見券肩建研県倹兼剣拳軒健険圏堅検嫌献絹遣権憲賢謙鍵繭顕験懸元幻玄言弦限原現舷減源厳己戸古呼固股虎孤弧故枯個庫湖雇誇鼓錮顧五互午呉後娯悟碁語誤護口工公勾孔功巧広甲交光向后好江考行坑孝抗攻更効幸拘肯侯厚恒洪皇紅荒郊香候校耕航貢降高康控梗黄喉慌港硬絞項溝鉱構綱酵稿興衡鋼講購乞号合拷剛傲豪克告谷刻国黒穀酷獄骨駒込頃今困昆恨根婚混痕紺魂墾懇左佐沙査砂唆差詐鎖座挫才再災妻采砕宰栽彩採済祭斎細菜最裁債催塞歳載際埼在材剤財罪崎作削昨柵索策酢搾錯咲冊札刷刹拶殺察撮擦雑皿三山参桟蚕惨産傘散算酸賛残斬暫士子支止氏仕史司四市矢旨死糸至伺志私使刺始姉枝祉肢姿思指施師恣紙脂視紫詞歯嗣試詩資飼誌雌摯賜諮示字寺次耳自似児事侍治持時滋慈辞磁餌璽鹿式識軸七𠮟失室疾執湿嫉漆質実芝写社車舎者射捨赦斜煮遮謝邪蛇尺借酌釈爵若弱寂手主守朱取狩首殊珠酒腫種趣寿受呪授需儒樹収囚州舟秀周宗拾秋臭修袖終羞習週就衆集愁酬醜蹴襲十汁充住柔重従渋銃獣縦叔祝宿淑粛縮塾熟出述術俊春瞬旬巡盾准殉純循順準潤遵処初所書庶暑署緒諸女如助序叙徐除小升少召匠床抄肖尚招承昇松沼昭宵将消症祥称笑唱商渉章紹訟勝掌晶焼焦硝粧詔証象傷奨照詳彰障憧衝賞償礁鐘上丈冗条状乗城浄剰常情場畳蒸縄壌嬢錠譲醸色拭食植殖飾触嘱織職辱尻心申伸臣芯身辛侵信津神唇娠振浸真針深紳進森診寝慎新審震薪親人刃仁尽迅甚陣尋腎須図水吹垂炊帥粋衰推酔遂睡穂随髄枢崇数据杉裾寸瀬是井世正生成西声制姓征性青斉政星牲省凄逝清盛婿晴勢聖誠精製誓静請整醒税夕斥石赤昔析席脊隻惜戚責跡積績籍切折拙窃接設雪摂節説舌絶千川仙占先宣専泉浅洗染扇栓旋船戦煎羨腺詮践箋銭潜線遷選薦繊鮮全前善然禅漸膳繕狙阻祖租素措粗組疎訴塑遡礎双壮早争走奏相荘草送倉捜挿桑巣掃曹曽爽窓創喪痩葬装僧想層総遭槽踪操燥霜騒藻造像増憎蔵贈臓即束足促則息捉速側測俗族属賊続卒率存村孫尊損遜他多汰打妥唾堕惰駄太対体耐待怠胎退帯泰堆袋逮替貸隊滞態戴大代台第題滝宅択沢卓拓託濯諾濁但達脱奪棚誰丹旦担単炭胆探淡短嘆端綻誕鍛団男段断弾暖談壇地池知値恥致遅痴稚置緻竹畜逐蓄築秩窒茶着嫡中仲虫沖宙忠抽注昼柱衷酎鋳駐著貯丁弔庁兆町長挑帳張彫眺釣頂鳥朝貼超腸跳徴嘲潮澄調聴懲直勅捗沈珍朕陳賃鎮追椎墜通痛塚漬坪爪鶴低呈廷弟定底抵邸亭貞帝訂庭逓停偵堤提程艇締諦泥的笛摘滴適敵溺迭哲鉄徹撤天典店点展添転塡田伝殿電斗吐妬徒途都渡塗賭土奴努度怒刀冬灯当投豆東到逃倒凍唐島桃討透党悼盗陶塔搭棟湯痘登答等筒統稲踏糖頭謄藤闘騰同洞胴動堂童道働銅導瞳峠匿特得督徳篤毒独読栃凸突届屯豚頓貪鈍曇丼那奈内梨謎鍋南軟難二尼弐匂肉虹日入乳尿任妊忍認寧熱年念捻粘燃悩納能脳農濃把波派破覇馬婆罵拝杯背肺俳配排敗廃輩売倍梅培陪媒買賠白伯拍泊迫剝舶博薄麦漠縛爆箱箸畑肌八鉢発髪伐抜罰閥反半氾犯帆汎伴判坂阪板版班畔般販斑飯搬煩頒範繁藩晩番蛮盤比皮妃否批彼披肥非卑飛疲秘被悲扉費碑罷避尾眉美備微鼻膝肘匹必泌筆姫百氷表俵票評漂標苗秒病描猫品浜貧賓頻敏瓶不夫父付布扶府怖阜附訃負赴浮婦符富普腐敷膚賦譜侮武部舞封風伏服副幅復福腹複覆払沸仏物粉紛雰噴墳憤奮分文聞丙平兵併並柄陛閉塀幣弊蔽餅米壁璧癖別蔑片辺返変偏遍編弁便勉歩保哺捕補舗母募墓慕暮簿方包芳邦奉宝抱放法泡胞俸倣峰砲崩訪報蜂豊飽褒縫亡乏忙坊妨忘防房肪某冒剖紡望傍帽棒貿貌暴膨謀頰北木朴牧睦僕墨撲没勃堀本奔翻凡盆麻摩磨魔毎妹枚昧埋幕膜枕又末抹万満慢漫未味魅岬密蜜脈妙民眠矛務無夢霧娘名命明迷冥盟銘鳴滅免面綿麺茂模毛妄盲耗猛網目黙門紋問冶夜野弥厄役約訳薬躍闇由油喩愉諭輸癒唯友有勇幽悠郵湧猶裕遊雄誘憂融優与予余誉預幼用羊妖洋要容庸揚揺葉陽溶腰様瘍踊窯養擁謡曜抑沃浴欲翌翼拉裸羅来雷頼絡落酪辣乱卵覧濫藍欄吏利里理痢裏履璃離陸立律慄略柳流留竜粒隆硫侶旅虜慮了両良料涼猟陵量僚領寮療瞭糧力緑林厘倫輪隣臨瑠涙累塁類令礼冷励戻例鈴零霊隷齢麗暦歴列劣烈裂恋連廉練錬呂炉賂路露老労弄郎朗浪廊楼漏籠六録麓論和話賄脇惑枠湾腕"

#データセット生成
import tqdm
images = Mincho_images + Gothic_images 

data_len = len(images)
labels = np.hstack([Mincho_labels,Gothick_labels])

#順序をシャッフルする
shuffle_index = np.arange(data_len)
np.random.shuffle(shuffle_index)

images = np.array(images)
images = images[shuffle_index]
labels = labels[shuffle_index]

kanji_hlac = []

#二値のHLAC特徴量抽出
for index in tqdm.tqdm(range(data_len)):
  hlac = extract_hlac(images[index],hlac_filters)
  kanji_hlac.append(extract_hlac(images[index],hlac_filters))

#XGboost 分類器で分類
eval_hlac = np.vstack(kanji_hlac[1200:])
eval_label = labels.flatten()[1200:]

from xgboost import XGBClassifier
for data_num in [50,100,250,500,1000]: 
  train_data = np.vstack(kanji_hlac[0:data_num])
  train_label = labels.flatten()[0:data_num]
  #xgboost のLoss確認に使いたかったら使う
  dev_data = np.vstack(kanji_hlac[1000:1200])
  dev_label = labels.flatten()[1000:1200]

  print(train_label)

  model = XGBClassifier(early_stopping_rounds=1,n_estimators=1000)
  model.fit(train_data, train_label,verbose=True)  

  pred = model.predict(eval_hlac)
  print(f"{data_num} datas accuracy is {((pred == eval_label).sum()/pred.shape[0] )}")

結果

上に示したプログラムで得られる学習につかうデータ数と推論精度の関係を以下の図に示します。 250データあれば性能が95%を超えているので、手動のアノテーションで十分対応可能な規模だと思います。

学習データ数と、推論精度
MNISTの実験と比べるとなかなか良い結果が得られたんじゃないでしょうか。

というわけで、卒業論文のフォントをメチャクチャにしてしまう学生が現れても、HLACで異常検知出来ることがわかりました。