WallStudio

技術ブログや創作ブログに届かない雑記です

喋る同人誌が作りたい!④ メソッドの完全見直編

せ や な(同意)

…ということで完全に1から考え直します(´;ω;`)こういうリーズナブルな意見というのは非常にありがたいものです.不良が更生すると評価されてしまうように,元々ブラックすぎる制作(AR同人誌然り,けだまっち然り)をしていたため,ちょっと楽になっただけで過大評価してしまっていたようです.しかし,よく考えると神絵師大先生がちまちま穴をあけるとかありえないですよね.テクノロジーは利用者ができるだけ何もしなくてよい形であるべきです.

では原点に返って考え直しましょう.

やりたいことは同人誌のページめくりに合わせて音声を再生する(読み手がめくるタイミングを決める紙芝居のように)ことです.その問題の技術的本質は「開いているページを認識すること」です.

一番初めに思いついた方法は,NFCタグを全ページに貼り付ける方法です.タグはアンテナ込みで1mm四方程度のものが存在します.ページ数/2のタグが必要になりますので20ページ程度でも500円/冊はかかってしまいます.

LXMSJZNCMF-198 38円/個~

時系列的にその次がAR同人誌です.ARなのでページを正面から撮影し認識をしています.正面からなら楽なんですよね~でもこれを作ってみて感じたのが手にスマホを持ちながら…というのが非常に残念.画面越しに見ると小さくなってしまいますし,直に見ようとするとスマホがページを覆う位置に来るため非常に煩わしいです.(私が描くような同人誌には多分関係ないけど両手が塞がってしまう←)

ARには二つの要素があって,一つは対象物が「何か」の認識,もう一つは座標と向きの推定です.この作品の工数(ルーズリング綴じ,ベースマーカ部分の切り抜き)が必要になった原因は後者であって前者の要素だけであればこの実は加工は全て不要でした.ただ,私の意識に「これぐらい手間はかかるよね」という認識を残していったおかげで,穴あけなんて大したことないと思ってしまった原因でした.

正面が駄目なら裏面から見ればいいよねで出てきたのがここまでの穴をあける手法.

新しい手法

正面,裏面が駄目なら側面です.側面といっても,完全に平行に見たらページは線になってしまうので少し角度を付けます.基本的には正面からの撮影→認識と同じなのでできると思われるのですが,物凄い歪んでしまって類似度推定のアルゴリズムが全く使えません

f:id:yukawallstudio:20180531032939j:plain


実際にカメラが見ている画像と対応する画像

まずは歪みを軽減するために透視変換をします.ページの四つ角が手に入れば最高ですが,この画像では比較的マシですがもっとページの盛り上がりによる歪み(以降,ふくらみ歪み)が入ってきて,直線検出のハフ変換が使えません.ページ内のコンテンツによってヒストグラムも大きく変わり,最悪端まで黒ベタだったりするので閾値やエッジで抜くのも難しいです.しかし,調べてみたところスマホのカメラの画角は大体90°前後らしいそうなのでここから大きく外れる機種の場合は自分で調整してね☆(๑´ڡ`๑)テヘってことにします.

class CorrectedImage:
    def __init__(self, image:np.ndarray, vanisingRate:(float,float), pageAreaRatio:float, name="???", drawProcessing=False, size=256):
        ...
        # 消失点から4つ角を計算
        h, w = self.resultImage.shape
        self.vanising = (int(vanisingRate[0]*w), int(vanisingRate[1]*h))
        self.pageAreaY = int(pageAreaRatio*h)
        self.cross0 = (int((1-((self.pageAreaY-self.vanising[1])/(h-self.vanising[1])))*self.vanising[0]), self.pageAreaY)
        self.cross1 = (w-int((1-((self.pageAreaY-self.vanising[1])/(h-self.vanising[1])))*self.vanising[0]), self.pageAreaY)
        # 透視変換の適用
        self.size = size
        self.pts1 = np.float32([self.cross0,self.cross1,(0,h),(w,h)])
        self.pts2 = np.float32([[0,0],[size,0],[0,size],[size,size]])
        self.PersMatrix = cv2.getPerspectiveTransform(self.pts1,self.pts2)
        self.resultImage = cv2.warpPerspective(self.resultImage,self.PersMatrix,(size,size))
        self.resultImage = cv2.flip(self.resultImage,-1)
        
        if drawProcessing:
            # 表示用
            color = (255,100,0,100)
            self.processingImage = np.copy(image)
            self.processingImage = cv2.line(self.processingImage, self.vanising, (0,h), color, thickness=3)
            self.processingImage = cv2.line(self.processingImage, self.vanising, (w,h), color, thickness=3)
            self.processingImage = cv2.line(self.processingImage, self.cross0, self.cross1, color, thickness=3)
            self.processingImage = cv2.circle(self.processingImage,self.cross0,5,color,3)
            self.processingImage = cv2.circle(self.processingImage,self.cross1,5,color,3)

するとこんな感じで補正できます.

f:id:yukawallstudio:20180531034517p:plain

そうしたら,この補正済み入力と事前に入れておくほんの原稿データとを順番に照合していきます.照合にはAKAZE特徴を使っています.まともに説明を始めるとボロが出るので,ごちゃっとしたところが出てくるみたいなイメージです.で,これがいい感じに対応しているものを出力します.

class LearndImage:
    
    _akaze = None
    
    def __init__(self, path='??', image=None, akaze=None):
        ...
        # AKAZE特徴点抽出
        self.keyPoints, self.descriptions = LearndImage._akaze.detectAndCompute(self.image, None)
                    
class LearndImageSet:

    def __init__(self, directoryPath:str):
        ...
        self.akaze = cv2.AKAZE_create()
        self.bfMatcher = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
        self._calc()
    
    def _calc(self):
        # 原稿データを先に処理しておく(カメラで正面撮ったがぞうでOK)
        self.learndImages = []
        for imagePath in self.imagePaths:
            learndImage = LearndImage(path=imagePath, akaze=self.akaze)
            self.learndImages.append(learndImage)
            
    def search(self, inputImagePath='', inputImage=None, drawResult=False) -> (LearndImage, float, list, np.ndarray):
        if inputImage is None:
            learndInputImage = LearndImage(path=inputImagePath,akaze=self.akaze)
        else:
            learndInputImage = LearndImage(image=inputImage,akaze=self.akaze)
        kepoint_i = learndInputImage.keyPoints
        descrip_i = learndInputImage.descriptions
        # 類似度が最大(scoreが最小)の原稿データを探す
        bestScore = 1000000.0
        bestMatches = None
        best = None
        scores = []
        for learndImage in self.learndImages:
            kepoint_d = learndImage.keyPoints
            descrip_d = learndImage.descriptions
            matches = self.bfMatcher.match(descrip_i,descrip_d)
            matches = sorted(matches, key = lambda x:x.distance)
            distancies = [m.distance for m in matches]
            score = sum(distancies)/len(distancies)
            scores.append(score)
            if  score < bestScore:
                bestScore = score
                best = learndImage
                bestMatches = matches

        if drawResult:
            resultImage = self._resultShow(best, learndInputImage, bestMatches, bestScore)
            return (best, bestScore, scores, resultImage)        

        return (best, bestScore, scores, None)

    def _resultShow(self, dataImage:LearndImage, inputImage:LearndImage, matches, score:float) -> np.ndarray:
        #表示用
        resultImage = cv2.drawMatches(inputImage.image, inputImage.keyPoints, dataImage.image, dataImage.keyPoints, matches[:10], None,flags=2)
        h,w,_ = resultImage.shape
        if score < 120: textColor = (255,255,255)
        else: textColor = (50,50,255)
        resultImage = cv2.putText(resultImage, str(int(score)), (0,h-20), cv2.FONT_HERSHEY_DUPLEX, 1,textColor, thickness=2)
        return resultImage

f:id:yukawallstudio:20180531034455p:plain

手元にあった同人誌を使ってもっと実験をしてみたところ(ほぼ真っ白のページなどは除き)95%程度の精度が出ました.5%ってまずいんじゃ…(^ω^;)と思うかもしれませんが,時間方向に平滑化するので多分大丈夫でしょう.(失敗しても1°回すとうまくいったりする)

今後の予定

今はPythonで描いているのですが,今週末にAndroid アプリの方に移植して使用感を見てみたいと思います.絵も描かなくてはいけないことを考えると,ここからまたちゃぶ台返しをするわけにもいかないですが.

追伸

沢山のリツイート,いいねありがとうございます!モチベーションゴリラになってます!いいね200なんて初めて見ましたよ!!私のポンコツスマホが嬉しい悲鳴を上げております.