月別アーカイブ: 2022年12月

Get first character from string in image

画像の中の文字列から最初の一文字を取得する

最終的な目標は、カタカナ1文字で解答が書かれた解答欄の『自動採点』。
これを実現するには、まず、解答欄画像からカタカナを探して、それを切り抜き、それが何という文字なのかを判定しなければならない。これは、そのチャレンジの第一歩。
記事(内容)は最初Pythonですが、最終的にPythonのスクリプトは、Object Pascal に埋め込んで内部的に呼び出して利用するので、やがてDelphiに変わります・・・

1.手書き文字は認識できない?
2.文字の位置座標を取得するには?(その①)
3.文字の位置座標を取得するには?(その②)
4.まとめ
5.お願いとお断り

1.手書き文字は認識できない?

本当は、手書きの日本語でも、英語でも、数字でも、とにかく解答欄に書いてある内容を自由自在に読み取ってデータ化し、採点等、様々な目的に利用したいのだが、残念ながらそれはできない。

「それは無理・できるわけない」と思っていることこそ、本当にチャレンジし甲斐のある課題だと、いつも思うし、実際、過去5年間にやってきたことはいつも「できない」を「できる」に変えることへの挑戦だった。

上司に言われて作ったICカードを利用した出退勤の記録を管理するプログラムや、ほとんど自分のために作ったマークシートリーダー、それから、喜んでくれる人が多そうだから書いた(自分では絶対に使わないけど)手書き答案の採点プログラム・・・。「できない」を「できる」に変えて行く、その過程がたまらなくおもしろいことを、僕は知ってる。

しかし、記述式の解答欄を自由自在に読み取って自動採点することは、現時点では絶対に不可能だし、チャレンジしてもハードルが高すぎて、間違いなく途中で挫折する。それはやるまでもなく実現不可能な夢だと自分でもわかる。でも、カタカナ1文字を読み取って、その正誤を判定することならできるんじゃないか? そう思う僕がいる。

確か・・・4年前に、マウスでドラッグして画面に描いた(手書き)数字が0~9のいずれかを判定するプログラムを作成したことがあった。あれから4年経ってるから、認識技術も格段に進歩しているじゃないかって、期待する気持ちもある。

OCRを使った文字認識について、ここ数日間かけて調べたり、実際にプログラムを写経して動かしてみてわかったことは・・・『手書き文字の認識は難しい』ってこと。

『Python 文字認識』のようなキーワードで検索すると、PyOCRとTesseractを使った例が読み切れないほどヒットする。解答欄に見立てた画像を用意して、実際に試してみた・・・。

# PyOCRとTesseractを使用して手書き文字画像を認識
import os
import sys
from PIL import Image
import pyocr

tesseract_path = "C:\Program Files\Tesseract-OCR"
if tesseract_path not in os.environ["PATH"].split(os.pathsep):
    os.environ["PATH"] += os.pathsep + tesseract_path

tools = pyocr.get_available_tools()
if len(tools) == 0:
    sys.exit(1)
else:
    tool = tools[0]

img = Image.open("Image/画像ファイル名.jpg")
# デフォルト設定
builder = pyocr.builders.TextBuilder(tesseract_layout=3)
result = tool.image_to_string(img,lang="jpn",builder=builder)
print(result)
最もよく認識できた手書き文字の画像(実際に使用する解答欄同様に作成)
# 結果
ss アイ ウェ オ
[Finished in 1.953s]

①は ss に、デフォルト設定の tesseract_layout=3 で、エ は ェ として認識された。他のパラメータ設定ではどうだろうか? 次々と変更して試すことにする。結果は、次の通り・・・

# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
ss アイ ウェ オ
[Finished in 1.959s]

変化なし!

# 画像を1行の文字列として認識
builder = pyocr.builders.TextBuilder(tesseract_layout=7)
# 結果
ss アイ ウェ オ
[Finished in 0.423s]

これも変化なし!

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
oe アイ ウェ イオ
[Finished in 0.419s]

上記のテストが最も上手く認識できた例で、他にも複数の手書き文字を試したが、この例ほど正しく認識することは出来なかった。いちばん上手く認識できた例でも、完全ではないことから、誰もが容易に、かつ無償で利用できる環境での手書き文字認識には、まだ難しい部分があるようだ。

追記 正しく認識できなかった例

例えば、次の画像。比較的よく認識できた上の例と、それほど違いはないと思うのだが・・・

# デフォルト設定
builder = pyocr.builders.TextBuilder(tesseract_layout=3)
# 結果
Y ィ ウエ オォ
[Finished in 2.258s]

デフォルト設定の3では、ア が Y となってしまった。イ も小さな ィ になっている。それから、オはなぜ、オとォになるのだろう?

# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
oe マイ ウエ オ
[Finished in 0.435s]

6だと、①は oe に、ア は マ と認識された。残りは正解。

# 画像を1行の文字列として認識
builder = pyocr.builders.TextBuilder(tesseract_layout=7)
# 結果
o アマ アイウエオ
[Finished in 1.966s]

7では、① が o 、その後ろの アマ はどこからやってきたんだろう?

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
Y ィ ウエ オォ
[Finished in 0.44s]

11では、デフォルト設定と同じ結果になった。

ただ、PyOCRとTesseractの名誉のために、これは重要なことだと思うので掲載。認識する対象画像が手書き文字でなければ、必要かつ十分な素晴らしい性能を発揮する。

手書きでない画像を読ませてみる
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
1 アイ ウエ オ
[Finished in 2.254s]

①は「(半角の)1」として読み取り、あとは正解。「文字」でなく、「画像」から文字の部分を判別し、しかも、その個々の文字を見分けているのですから、素直にすごいです!

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウエ オ
[Finished in 2.419s]

①は認識せず。あとは正解。では、Fontを変更すると・・・?

別のFontで試すとどうなるか?
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
"アイウエオ
[Finished in 0.36s]

①が ” に変わった? でも、あとは正解。

# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウエ オ
[Finished in 2.376s]

①は認識されない。あとは正解。さらに、別のFontでは・・・?

さらに別のFontでは?
# テキストの単一ブロックと仮定して認識
builder = pyocr.builders.TextBuilder(tesseract_layout=6)
# 結果
アイ ウエ オ
[Finished in 0.779s]
# 文字が散らばっているテキストとして認識
builder = pyocr.builders.TextBuilder(tesseract_layout=11)
# 結果
アイ ウツ ウエ オ
[Finished in 1.944s]

なんだか、むせてるような雰囲気を感じますが。元気? だいじょうぶ?

これから作成する自動採点プログラムで使用する解答欄画像には、設問番号や解答方法の指示が必ず含まれる予定なので、OCRを利用した手書き文字認識をそのまま読み取りに使用するのは少し難しそうだ。どうにかして、①などの設問番号への反応を読み取り結果から除外する方策を考えなければならない。

あと、認識結果のところどころにスペースが入るのは仕様なのか?(この点、不勉強で、よくわかりません)、まぁ、スペースは置換して取り除けばそれでOKだから気にしなくてもいいけど。

いずれにしても「手書きでない文字画像の認識」であれば、現在のOCR(光学的文字認識)は十分、実用になるレベルに達しているのは間違いない。これを無償で提供してもらえることに対し、プログラムの作成者と提供の仕事に関わった人々へ心から感謝。

2.文字の位置座標を取得するには?(その①)

次に考えたことは、手書き文字そのものを完全に認識(読み取って判別すること)は難しくても、「文字の位置」であれば座標というかたちで取得できるのではないか? ということ。僕の場合、そこに文字列があったとしても、最初の一文字だけ座標が取得できればOKなので、そこを重要視してテストを実行。

調べてみると、pyocrはbuilderというオプション部分の設定を変更することで、様々な読み取り形式を指定できるようだ。

※ pyocr のライセンスは GPL v3 なので、個人がローカルな環境で「使用」するだけであればソースコードの公開義務等は発生しないが、複製や改変、頒布は「利用」にあたり、ソースコードの公開義務等が発生することに十分留意する必要がある。

【builderに指定可能なオプション】(省略した場合はTextBuilderになる)

1.TextBuilder 文字列を認識
2.WordBoxBuilder 単語単位で文字認識
3.LineBoxBuilder 行単位で文字認識
4.DigitBuilder 数字 / 記号を認識
5.DigitLineBoxBuilder 数字 / 記号を認識

注意:DigitBuilderおよびDigitLineBoxBuilderは認識対象を数字に限定してTesseractを動作させる。ただし、新しい認識エンジン(Tesseract4.0系)では動作しない。

このオプションで、座標を取得するために利用できそうなのは、「2」と「3」だ。WordBoxBuilderが認識するのが「文字」ではなく、「単語」という部分がちょっと気になったが、「アイウエオ」は単語ではなく、「単なる文字列」だと思うので、どうなるか、ちょっと実験してみた。ア と イ と ウ と エ と オ を全部、別々の単語として認識してくれるとうれしいのだけれど。字と字の距離も離れているし・・・

手書きカタカナ画像
import sys
import os

from PIL import Image
from PIL import ImageDraw
from PIL import ImageFont

import numpy as np
import pyocr

img = Image.open(r".\img\画像ファイル名.jpg")

#tool = pyocr.get_available_tools()[0]

tesseract_path = "C:\Program Files\Tesseract-OCR"
if tesseract_path not in os.environ["PATH"].split(os.pathsep):
    os.environ["PATH"] += os.pathsep + tesseract_path

tools = pyocr.get_available_tools()
if len(tools) == 0:
    print("OCRエンジンが指定されていません")
    sys.exit(1)
else:
    tool = tools[0]

builder = pyocr.builders.WordBoxBuilder(tesseract_layout=6)
boxies = tool.image_to_string(img, lang="jpn", builder=builder)
draw = ImageDraw.Draw(img)

for box in boxies:
    text = box.content
    pos = box.position
    draw.rectangle(pos, fill=None, outline=(255, 0, 0))
    print(pos)

img.save(r".\img\out.jpg")

結果は・・・

# 出力された座標
((6, 0), (82, 98))
((67, 32), (181, 77))
((271, 27), (379, 82))
((417, 17), (481, 72))
[Finished in 3.125s]

WordBoxBuilderで最初の一文字の座標を取得するという目的の実現は困難なようだ。「使い方の方向性が間違ってる」と、作成者の方には叱られそう・・・。その通りです。すみません。

では、LineBoxBuilder の方はどうだろうか?

# builderオプションの設定を変更
# builder = pyocr.builders.WordBoxBuilder(tesseract_layout=6)
builder = pyocr.builders.LineBoxBuilder(tesseract_layout=6)

左x座標の起点が①の左にあるのは理解できるが、左y座標はどのようにして決めているのだろう? 左y座標は、点線の上限のようにも見えるが・・・。右x座標はオの右端だと思うが、右y座標はなぜ画像の外に・・・? もしかして、画像中の点線と何か、関係がある?

残念ながら、LineBoxBuilder も手書き文字一文字めの座標を取得する用途には向かないようだ。誤解のないように言っておくが、これは各Builderオプションが「使えない」という意味ではない。Builderは正しく機能している。僕の使い方=「手書き文字への適応」が「間違っている」ということに他ならない。

Builderオプションの名誉のため、手書きでない画像の①と点線を消して、WordBoxBuilder をもう一度実行して見ると・・・

使用目的によっては、日本語の単語をどの程度認識するか? その認識率が問題になりそうだが、文字や文字列の座標は間違いなく取得できる。すばらしい性能だ。

LineBoxBuilder では・・・

こちらはさらにすばらしい。手書きでない画像であれば、行単位での文字列の認識・切り出し用途に十分使えそうだ。要は、万能選手的な働き方はその性格上できないので、適材適所な使い方をすれば、大変優秀な働きを示してくれるということを、使う側が十分認識して、Toolがその性能を100%発揮できる場面を提供してあげることが大切なのだ。

3.文字の位置座標を取得するには?(その②)

では、手書き文字の最初の一文字目の座標を取得するにはどうしたらいいのか?
いちばんのヒントを与えてくれたのは、次のWebサイト様で紹介されていた画像内の数字を連続して認識するスクリプト。

PYTHONとRPAで遊ぶ 画像OCR(連続文字の認識)

https://ailog.site/2019/08/17/ocr1/

上記Webサイト様で紹介されているScriptをそのまま実行しようとすると、次のようなエラーメッセージが表示されることがあるようだ。これは、OpenCVの仕様変更のため?

# 表示されたエラーメッセージ
Traceback (most recent call last):
  File "X:\XXX\Sample.py", line XX, in <module>
    x, y, w, h = cv2.boundingRect(cnt)
cv2.error: OpenCV(4.5.5) X:\a\opencv-python\opencv-python\opencv\modules\imgproc\src\shapedescr.cpp:874: error: (-215:Assertion failed) npoints >= 0 && (depth == CV_32F || depth == CV_32S) in function 'cv::pointSetBoundingRect'

[Finished in 2.412s]

エラーの修正方法は、次の通り(赤字部分の[1] を [0] に修正)。また、状況によっては、いちばん外側の輪郭だけを検出するように、RETR_LISTRETR_EXTERNAL に修正しておいた方がよいとのこと。

#contours = cv2.findContours(
#    thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[1]

# エラーが出なくなるように修正
#contours = cv2.findContours(
#    thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)[0]

# 領域の一番外側だけを検出するように修正
contours = cv2.findContours(
    thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]

紹介記事にあったスクリプトを元にして、解答欄の画像にある文字列から最初の一文字目の文字を取得するスクリプトを書いた。処理の流れとスクリプトは次の通り。

  • 検出した輪郭矩形の数だけ、その位置座標を二次元配列に取得(高さが小さすぎる輪郭は座標をゼロにしてないものとする)。
  • 横書き答案の場合はx座標の小さなものから昇順に、縦書き答案の場合はy座標の小さなものから昇順に並べ替えを行う処理を追加し、文字列の最初の一文字目の座標を取得。
  • さらにこれを囲む輪郭矩形の大きさを少し大きく補正して、一文字目の文字だけを切り出して保存する。
# 画像の中の文字を検出して、最初の一文字の画像を保存するScript

import sys
import numpy as np
import cv2
from PIL import Image

# 画像の読み込み(Pathとファイル名に日本語があるとエラーになる)
#im = cv2.imread(r".\img\numbers.png")

# 画像の読み込み(pillowで日本語に対応)
file_path = r".\img\Sample.jpg"
pil_img = Image.open(file_path)

# PillowからNumPyへ変換
img = np.array(pil_img)

# グレイスケールに変換
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# ぼかし処理(シミ抜き)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# 二値化
thresh = cv2.adaptiveThreshold(blur, 255, 1, 1, 11, 2)

# 輪郭を抽出
contours = cv2.findContours(
    thresh, cv2.cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]

# 検出した輪郭の数
num = len(contours)
# 表示して確認
print(len(contours))

# 二次元配列を作成して初期化
mylist = np.zeros((num, 4))

# 配列の要素番号を指定する変数(初期化)
i = 0

# 描画色は赤を指定
red = (0, 0, 255)

# 抽出した領域を繰り返し処理する
for cnt in contours:
    x, y, w, h = cv2.boundingRect(cnt)

    if h < 30:  # 高さが小さい場合は検出しない
        # (ここを調整すれば設問番号や指示内容を無視できる)
        mylist[i][0] = 0
        mylist[i][1] = 0
        mylist[i][2] = 0
        mylist[i][3] = 0
    else:
        mylist[i][0] = x
        mylist[i][1] = y
        mylist[i][2] = x + w
        mylist[i][3] = y + h
        cv2.rectangle(img, (x, y), (x+w, y+h), red, 2)
        # 最後の出力がいちばん手前の矩形の座標というのは勝手な思い込み
        # 最初の一文字目の座標を確認するために取得した座標を表示
        print(str(x)+", "+str(y)+", "+str(x+w)+", "+str(y+h))

    i += 1

# 配列を並べ替え(キー列は0:x1、1:y1・昇順)
pw, ph = pil_img.size
if pw > ph:
    # 横書きと判断
    mylist_sort = mylist[np.argsort(mylist[:, 0])]
else:
    # 縦書きと判断
    mylist_sort = mylist[np.argsort(mylist[:, 1])]

# 表示して確認
print(mylist_sort)

# 緑色を指定
green = (0,255,0)

# 輪郭枠の大きさを微調整するための変数
intTweak = 5

# 最も左の文字を緑の枠で囲んで示す
for i in range(num):
    if mylist_sort[i][0] != 0:
        x1=int(mylist_sort[i][0]-intTweak)
        y1=int(mylist_sort[i][1]-intTweak)
        x2=int(mylist_sort[i][2]+intTweak)
        y2=int(mylist_sort[i][3]+intTweak)
        print(str(x1)+", "+str(y1)+", "+str(x2)+", "+str(y2))
        cv2.rectangle(img, (x1,y1), (x2, y2), green, 2)
        break

# 輪郭枠付きの画像を保存
cv2.imwrite(r".\img\result.png", img)
# 最初の一文字を切り抜き
img_crop = pil_img.crop((x1,y1,x2,y2))
# 最初の一文字を保存
img_crop.save(r".\img\crop.jpg")

# 表示
cv2.imshow('rect', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
【実行結果】

【検出した輪郭の数】
 23

【最初の一文字目の座標を確認するために取得した座標を表示】
262, 36, 296, 83
332, 35, 380, 68
66, 34, 105, 78 <- これがほしかった座標!
146, 32, 181, 77
416, 16, 482, 73

【並べ替えを実行した後の二次元配列の内容】
[[  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [  0.   0.   0.   0.]
 [ 66.  34. 105.  78.]
 [146.  32. 181.  77.]
 [262.  36. 296.  83.]
 [332.  35. 380.  68.]
 [416.  16. 482.  73.]]

【少し輪郭矩形を大きくした切り抜き用の座標】
 61, 29, 110, 83

[Finished in 11.51s]
ようやく最初の一文字目を取得できた!
正誤判定用に一文字目を切り抜いて保存
縦書きの解答欄の場合もOK!

追記 読み取りに失敗(?)した例

文字の一部が「切れている・つながっていない」場合、読み取りに失敗(?)してしまうことがあるようだ(ヒトは、その期待に反している結果を「失敗」と感じてしまうが、キカイ的には間違いなく正確に輪郭を検出している)。だから、これも僕が運用方法を間違えているだけと言えなくもない。例えば、次の画像で試すと・・・

ア・ウ・エとも、文字の一部がつながっていない
「輪郭」を検出しているわけだから、プログラム的には大正解のはず

高さの閾値をゼロにして、プログラムが正しく動作していることを確認する。

OpenCVの性能は素晴らしい!

この読み取り方法での(自分的に工夫はしたが)限界がこの辺りにあることがわかったので、運用する際はこれを「読み取りエラー」として処理し、ヒトが目視で確認するように案内することに決め、これ以上、この問題には深入りしないことにする。確かに「自動採点が目標」なんだけど、最終的にはヒトの確認作業が必ず必要。機械と協働するのだからお互いが気持ちよく働ければいいのだ・・・と、自分自身に言い聞かせて、先へ進む。

(追記ここまで_20221231)

スクリプトを書いていて、すごくうれしくなったのは、解答欄に欠かせない設問番号や指示内容を小さめに作成(印刷)すれば、高さの閾値を用いて、その存在を無視できること。これを発見した時は、もう小躍りしたいほど、うれしかった!

1回の計算に必要な時間が長いような気もするが、Loopを作って複数の解答欄画像を連続して処理して見ると2回目からは初期化が必要なくなるためか、計算時間はグンと短くなった。

次の課題は、切り出して保存したカタカナ画像の文字が何であるかを判定するスクリプトを完成させること。4年ぶりにチャレンジする機械学習だが、この4年間でどれくらい進化したのだろう? 4年前はMNISTデータベースを活用して手書き数字を認識・判定する方法を学んだが、今回のターゲットは言語的にはマイナーすぎるカタカナ・・・

もし、この夢が実現できてもプログラムを商品化するつもりなどまったくないので、研究用に無料で利用できるカタカナデータベースがどこかにあるとよいのだが。

4.まとめ

(1)画像内の手書き文字をOCRで完全に認識させるのは、現段階では難しい。
(2)OpenCVの輪郭検出を用いれば画像内の文字位置の座標を取得可能。
(3)座標から文字の輪郭矩形を切り出して保存。機械学習で文字種を判定する。

5.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

Rectangle Detector

矩形検出器

手書き答案をスキャナーで画像化して採点するソフトを書いた。概ね、思った通りにカタチになったが、解答欄の位置座標を取得するのに、解答欄の数だけ、その左上隅から右下隅へマウスでドラッグする作業を繰り返さなくてはならない。(もし、これが自動化できたら・・・) そう思って書いたのが、このプログラム。

1.矩形の検出方法
2.字数制限のある解答欄の作り方
3.GUIはDelphiで作成
4.矩形検出器の使い方
5.まとめ
6.お願いとお断り

1.矩形の検出方法

キーワードを『矩形 検出』にしてGoogle先生にお伺いをたてると、思った通りOpenCVを活用する方法がいくつもヒットする。しかも、そのほとんどすべてがPythonでの活用方法だ。Delphi用のOpenCVもあるようだけれど、次の理由から矩形の検出はPython用のOpenCVで行うことにした。

Pythonを使う利点は、まず、何と言っても、情報が豊富なことだ。マイ・プログラミング環境では、わからないことはすべてGoogle先生に教えてもらうしかないので、情報が入手しやすいことは、他のすべてに優先する。

(メインの開発環境がDelphiなのは、上記の内容と大いに矛盾しますが・・・)

さらに、手書き答案の採点ソフトより前に、マークシートリーダーを作った時、マーク欄の座標を得るために、やはりPythonとOpenCVのお世話になった。マークシートリーダーも、手書き答案の採点ソフトも、embeddable pythonに入れたOpenCVと一緒のフォルダに詰め込んでユーザーに配布しているから、Pythonを内包して使う環境は既に完成済み。PythonのスクリプトをDelphiのコードに埋め込んで、PythonForDelphiを使って実行する方法は勉強済みだから安心。Delphi用のOpenCVは、情報も少ないし、何よりその使い方がわからない・・・。

他人様に使っていただくプログラムはDelphiで書くけれど、自分専用のToolはPython環境を利用して作ることが多い。ちょっと特別なことをしたい時、Pythonはとても便利だ。いろいろ紆余曲折はあったけれど、現在はSDカードにWinPythonとAtomエディタを入れて持ち運べるPython環境を作っている。

そのSDカードに入れたPython環境で、いつものようにAtomを起動し、Web上にあったいくつものScriptをコピペして試してみる。

まず、OpenCVで「ハフ変換」なるものを利用する例だが、ハフ変換はノイズの除去で苦労しそうだ。ノイズの発生源が多数存在する解答用紙の矩形検出でパラメータを適切に設定することが果たしてできるだろうか? 経験がない自分にはちょっと厳しそうだ。

次に、LSD(Line Segment Detectorの略とのこと)という直線検出器を試した。試した瞬間、(もう、これしかない!)と思うほど、これは凄かった。使い方も超カンタンで、LSDをこれでもか!とばかりに並べるだけ。

from pylsd.lsd import lsd
Mylines = lsd(picture)

【検出結果】

LSDで検出できた矩形の例

さらに驚くべきことに、こういう作業には付き物の引数も一切ない。つまり、パラメータを調整する必要など『ない』ということなのだろう・・・。ただ、LSDはそのライセンス形態がAGPLであると知り、使用を断念。MITやBSDでないと自分的にはやはり困る・・・。

最後に試したのが、OpenCVのfindContours関数。これを使うには前処理として、まず、画像をグレースケールに変換し、さらに白黒反転させて二値化しなければならない。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く(全角文字対応の確認用にファイル名は「ひらがな」)
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

Pillowで画像ファイルを開いているのは、OpenCVのimread関数が日本語(全角文字)に対して拒絶反応を示すので、これを回避するため。もし、ファイル名とそこまでのPathに全角文字が含まれないという確実な保証があるなら、次のようにしてもいいようだ。これなら1行で済む。

# 8ビット1チャンネルのグレースケールとして画像を読み込む
img = cv2.imread("全角文字のないPathと画像ファイル名", cv2.IMREAD_GRAYSCALE) 

で、準備が出来たらfindContours関数を使って輪郭を検出する。

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

解答欄には、その性格上、小さな矩形が多く使われることが多いので、閾値以下の面積の矩形は削除する。※ 閾値は整数型の数値で指定する。

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 閾値, contours))

よりスムーズに作業するためには、予め、小さな矩形を消去した機械読み取り用の解答欄(解答用紙)をヒト用の解答用紙のコピーから作成し、これを用いて解答欄座標を取得した方がよい(国語の縦書き解答用紙は、ワープロソフトではなく、表計算ソフトで作成する方法が業界では一般的らしいので、機械読み取り用の解答用紙はそれほど手間をかけなくても、カンタンに作成できる・・・はず)。

解答欄矩形をちゃんと認識できているか・どうかを確認するため、検出した輪郭を描画する。このPythonのスクリプトをDelphiのObject Pascalに埋め込んで実行する際は、ここが最大の「見せ場」になる。検出した矩形をグラブハンドル付きのラバーバンドで表示する方法は後述。

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

最後に解答欄矩形の座標を取得する(これが最終的な目標)。取得した座標は、採点順になるよう、並べ替えて表示する(並べ替え方法は後述)。

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

数値より、画像(絵)で見た方がわかりやすいのは言うまでもない。

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()
画像を表示して、解答欄矩形の取得状況を確認

ここまでの Python Script をまとめて示せば、次の通り。

import cv2
import numpy as np
from PIL import Image

# Pillowで画像ファイルを開く
pil_img = Image.open("./img/さんぷる.jpg")
# PillowからNumPyへ変換
img = np.array(pil_img)

# グレースケールに変換する
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 白黒を反転
gray = 255 - gray
# 2値化する
ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)

# すべての輪郭を同じ階層として取得する
contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)

# 閾値以下の面積の矩形(小さい輪郭)は削除
contours = list(filter(lambda x: cv2.contourArea(x) > 数値, contours))

# 検出した輪郭を描画する
cv2.drawContours(img, contours, -1, color=(0, 0, 255), thickness=2)

# 矩形の座標を表示(左上の座標, 右下の座標)
for i in range(len(contours)):
    x, y, w, h = cv2.boundingRect(contours[i])
    print(str(x)+','+str(y)+','+str(x+w)+','+str(y+h))

# 保存
cv2.imwrite('./img/lined.jpg', img)
# 画像を表示
cv2.imshow("Image", img)
# キー入力で終了
cv2.waitKey()

OpenCVのfindContours関数を使って検出した輪郭(=解答欄の矩形)の例。
(解答用紙画像はLSDを試した時と同じものを使用)

矩形を検出しやすいように作った解答用紙なら、この結果はまさに『ブラボー!』

解答用紙中の ■ や □ を検出しないよう、検出下限の閾値を設定したこともあり、期待した通りの満足できる結果が得られた。OpenCVのハフ変換や、LSDでは日本語に対する反応が見られたが、findContours関数は(適切な閾値を設定してあげれば)日本語に反応しないようだ。

答案の「答」には「口」、問にも「口」、漢字にはたくさんの矩形が使われている。適切な閾値を設定することで、誤認識を減らせることも理想的。

【実験してみた!】

閾値を「700」として、□ に対する反応を実験して確認した。結果は次の通り。

26×26=676、28×28=784 だから・・・機械は正確に反応している

28ポイントの「□」から反応するが、40ポイントの「問」には無反応。通常使用される解答用紙であれば、フォントの大きさに制限を設ける必要性はなさそう。

もう少し細かい矩形を使った解答用紙で、閾値700で実験すると・・・

解答欄の矩形をさらに細かく分割したサンプルを作成してテスト
解答欄の番号の矩形に反応してしまう・・・

閾値1400までは・・・

解答欄の番号の矩形に反応するが

閾値を1500にすると・・・

解答欄の番号の矩形には反応しなくなる☆

少し、細かい矩形を用いた解答用紙であれば、閾値1500くらいから試せば狙った通りに解答欄の座標だけを取得することができそうだ。

閾値に上限を設定すれば、さらに良い結果を得られるかも・・・と思ったが、数学の解答用紙には他の教科ではあり得ない巨大な矩形が普通に使用される。矩形を取得できなければ、検出器とは言えない。さらに、解答欄全体を一つの大きな矩形として認識してしまうのはプログラムの性格上、絶対に回避できないから、閾値の上限は設けずに、むしろ、不要な矩形の座標を削除しやすいプログラム(GUIを作成)を書けばいいと気づく。

さらに、ユーザーが矩形座標の編集(修正)を自由にできるようにプログラムを工夫すれば、理想的な矩形検出器ができるはず。

これでDelphiでGUIを作成する際の方向性も見えてきた。

2.字数制限のある解答欄の作り方

解答欄の矩形を検出する上で、大きなハードルになるだろうと予想していたのが『字数制限が設定された解答欄』。

機械読み取り用に作成した解答用紙であっても・・・

上の解答用紙は、ヒト用の解答用紙の問題番号部分にあった小さな矩形を消去して、機械読み取り処理用に作成した解答用紙。この状態で矩形を検出(閾値1500)すると・・・

それでも削除しなければならない矩形座標が多すぎ・・・

閾値を「3100」に設定して、ようやく・・・

閾値をどんどん大きくすれば、何とかなることはわかった!

閾値を大きく設定すれば、何とかなることは上の例でわかったが、閾値を大きくすれば当然必要な解答欄の座標を取得できなくなる可能性も生じてくるわけで・・・。

ところが別の国語用解答用紙を処理している際に、閾値を気にせずに字数制限のある解答欄を作成する良い方法があることを偶然発見。それは・・・

罫線に「点線」を利用した解答用紙

字数制限を設定したり、完全解答で正解としたい解答欄は内側の罫線を点線にする!

閾値「700」で実験した結果

これなら問題2の(1)・(2)が作る大きな矩形の座標のみ削除すればOK!
点線を活用することで、一番大きな問題を難なくクリアできることが判明。
やったー☆

【embeddable Pythonのバージョンとインストールしたライブラリの一覧】

Python 3.9.9

Package Version
numpy 1.21.5
opencv-python 4.5.4.60
Pillow 9.3.0
pip 22.3.1
setuptools 60.1.0
wheel 0.37.1

3.GUIはDelphiで作成

取得した解答欄の座標を編集するGUIはDelphiで作成。最終的にはこうなった。

検出した矩形の確認と編集を行うGUIはDelphiで作成

画面下の「操作」グループ内のVCLを左から右へ順にクリックして行けば、解答用紙画像から解答欄の矩形が取得・表示できる仕組み。

左から右へ順に操作して解答欄矩形の座標を取得する。

取得した解答欄矩形の座標は、画面右上に一覧形式で採点順に表示されるようにプログラミングした。

取得した座標の一覧を表示

横書き答案が指定された場合は、y座標の値が昇順になるよう並べ替え(y座標が同じなら、x座標でさらに昇順に並べ替え)。

縦書き答案が指定された場合は、x座標の値が降順になるよう並べ替え(x座標が同じなら、y座標でさらに昇順に並べ替え)。

こうすれば、座標の並び方が「ほぼ採点する順番になる」はず。なお、並べ替えはカンマで区切った解答欄矩形の座標を入れたStringListを対象として実行(解答欄数は多くても100未満のはず・・・だから、並べ替えの速度はまったく考えていない)。そのアルゴリズムは次の通り。まず、グローバルに使う変数、ソート用のプロパティと関数を準備。

  private
    { Private 宣言 }
    x1,x2:integer;
    y1,y2:integer;
    //Pythonから送られたデータを保存する
    strAnsList:TStringList;

var
  Form1: TForm1;

type TSStyle = (ssText,ssInteger);
var
  //ソート用のプロパティ
  fAscending : Boolean;
  fIndex : Integer; //項目番号
  fStyle : TSStyle; //テキストか整数か

implementation

uses
  System.UITypes;
function GetCommaText(aStr:String; aIndex:Integer):string;
  var
    subList:TStringList;
begin
  subList := TStringList.Create;
  subList.Delimiter := ',';
  subList.DelimitedText := aStr;
  Result := subList.Strings[aIndex];
  subList.Free;
end;
function MyCustomSort(List: TStringList; Index1, Index2: Integer): Integer;
begin
  case fStyle of
    ssText:begin
      Result:=CompareText(GetCommaText(List.Strings[Index1],
      fIndex),
      GetCommaText(List.Strings[Index2],fIndex));
    end;
    ssInteger:begin
      //一重ソート
      //Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
      //          -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      //二重ソート
      Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex))
                -StrToInt(GetCommaText(List.Strings[Index2],fIndex));
      if Result=0 then
        //-1することで1番目の項目がソートキーになる
        Result:=StrToInt(GetCommaText(List.Strings[Index1],fIndex-1))  
                  -StrToInt(GetCommaText(List.Strings[Index2],fIndex-1));
      if fAscending then
      begin
        Result:=Result*-1;
      end else begin
        Result:=Result*1;
      end;
    end;
  else
    //これを入れておかないとコンパイラが警告を表示する
    Result:=0;
  end;
end;

で、「解答欄座標を取得」ボタンがクリックされたら、PythonForDelphiを通じてPythonのScriptを内部的に実行して座標を取得し、上記関数を呼び出して並べ替えを実行、結果をMemo2に表示する。

procedure TForm1.btnGetSquareClick(Sender: TObject);
var
  //PythonのScriptを入れる
  strScrList:TStringList;
  //Pythonから送られたデータを保存する -> グローバル変数化
  //strAnsList:TStringList;
  //Sort
  i:integer;
  strFileName:string;
  strList:TStringList;
begin
  //初期化
  Memo1.Clear;
  //Scriptを入れるStringList
  strScrList:=TStringList.Create;
  //結果を保存するStringList
  strAnsList:=TStringList.Create;

  try
    //Python Script
    strScrList.Add('import cv2');
    strScrList.Add('import numpy as np');
    //strScrList.Add('img = cv2.imread("./ProcData/sample2.jpg")');
    strScrList.Add('img = cv2.imread(r"./ProcData/'+ExtractFileName(StatusBar1.SimpleText)+'")');
    strScrList.Add('gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)');
    strScrList.Add('gray = 255 - gray');
    strScrList.Add('ret, bin_img = cv2.threshold(gray, 20, 255, cv2.THRESH_BINARY)');
    strScrList.Add('contours, hierarchy = cv2.findContours(bin_img, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)');
    strScrList.Add('contours = list(filter(lambda x: cv2.contourArea(x) > '+cmbThreshold.Text+', contours))');
    strScrList.Add('for i in range(len(contours)):');
    strScrList.Add('    im_con = img.copy()');
    strScrList.Add('    x, y, w, h = cv2.boundingRect(contours[i])');
    strScrList.Add('    var1.Value =str(x)+","+str(y)+","+str(x+w)+","+str(y+h)');
    //Scriptを表示
    Memo1.Lines.Assign(strScrList);
    //Execute
    PythonEngine1.ExecStrings(Memo1.Lines);
    //結果を表示
    Memo2.Lines.Assign(strAnsList);
  finally
    //StringListの解放
    strAnsList.Free;
    strScrList.Free;
  end;

  strFileName:=ExtractFilePath(StatusBar1.SimpleText)+'Temp.csv';
  Memo2.Lines.SaveToFile(strFileName);

  strList := TStringList.Create;
  try
    for i := 0 to Memo2.Lines.Count-1 do
    begin
      strList.Add(Memo2.Lines[i]);
    end;
    //fAscending := True; //昇順で
    fAscending := False;
    fIndex := 1; //2番目の項目を
    fStyle := ssInteger; //整数型でソート
    strList.CustomSort(MyCustomSort); //ソート
    //データ抽出
    Memo2.Clear;
    for i := 0 to strList.Count - 1 do
    begin
      //Memo2.Lines.Add(GetCommaText(strList.Strings[i],fIndex));
      Memo2.Lines.Add(strList[i]);
    end;
  finally
    strList.Free;
  end;

end;

上記のアルゴリズムは、次のWebサイトに紹介されていた情報を元に作成。
カンマ区切りのデータの並べ替えは初めて行った。採点順に座標を並べたかったので、プログラムコードをよく読んで、二重ソートになるよう工夫した。
貴重な情報を投稿してくださった方に心から感謝申し上げます。

[delphi-users:1175] カンマ区切りのデータの並べ替え

https://groups.google.com/g/delphi-users/c/Ck2mQXNFTvw

4.矩形検出器の使い方

ここまでの操作で解答欄の座標はすべて取得できたはずなので、不要な矩形のデータをいかに効率よく削除するかを主眼に、GUIの操作方法を考えた。

まず、取得できた座標データの先頭にセットフォーカスし、そのデータが示す矩形を赤いラバーバンドで囲んで表示する。ユーザーは、ラバーバンドで囲まれた矩形を見て、その要・不要を判断。

この矩形は不要

不要な矩形であった場合は、「編集」ボタンをクリック。不要なデータを自動で選択状態に設定。

Memoの一行全部を選択状態に設定

手続きは次の通り。

procedure TForm1.BitBtn1Click(Sender: TObject);
var
  i:integer;
begin

  //行番号をLines[i]で取得
  i:=StrToInt(LBRow.Caption)-1;

  EditTF:= not EditTF;
  if EditTF then
  begin
    BitBtn1.Caption:='編集中';
    BitBtn1.Font.Color:=clRed;
    Memo2.ReadOnly:=False;
    btnSave.Enabled:=False;

    //i行目の文字全てを選択状態にしたい場合
    //先頭にカーソルをセット
    Memo2.SelStart:=Memo2.Perform(EM_LINEINDEX, i, 0);
    //全ての文字を選択
    Memo2.SelLength:=Length(WideString(Memo2.Lines[i]));
    //Memo2.Perform(WM_VSCROLL,SB_TOP,0); //先頭にスクロール

  end else begin

    BitBtn1.Caption:='編 集';
    BitBtn1.Font.Color:=clBlack;
    Memo2.ReadOnly:=True;
    Memo2.SelStart:=SendMessage(Memo2.Handle,EM_LineIndex,i,0);
    btnSave.Enabled:=True;
    Memo2Click(Sender);

  end;

  //SetFocus
  Memo2.SetFocus;

end;

Delete or Backspaceキーで不要なデータを削除すると同時に、Memoの行も削除する。で、ボタンを「編集」(=意味的には「編集したい場合はクリックせよ」)に戻す。次のデータをラバーバンドで囲む。この一連の動作がすべて自動的に流れ作業で行われるように手続きを作成。

コードは次の通り。

procedure TForm1.Memo2KeyUp(Sender: TObject; var Key: Word; Shift: TShiftState);
var
  LineNo:integer;
begin
  //現在、カーソルがある行を取得
  LineNo:=Memo2.Perform(EM_LINEFROMCHAR, UINT(-1), 0);
  //空欄なら行を削除
  if Memo2.Lines[LineNo]='' then
  begin
    Memo2.Lines.Delete(LineNo);
  end;
  //表示
  GetLinePos;
  if not EditTF then
  begin
    Memo2Click(Sender);
  end else begin
    BitBtn1Click(Sender);
  end;
end;
procedure TForm1.GetLinePos;
var
  CurPos,Line:Integer;
begin
  with Memo2 do
  begin
    CurPos:=SelStart;
    Line:=Perform(EM_LINEFROMCHAR, CurPos, 0);
    //LBRowは現在フォーカスがある行番号を表示するラベル
    LBRow.Caption:=Format('%d', [Line+1]);
    LBRow2.Left:=LBRow.Left+LBRow.Width;
    LBRow2.Caption:='行目';
  end;
end;
procedure TForm1.Memo2Click(Sender: TObject);
var
  i:integer;
  p1,p2:TPoint;

  function RemoveToken(var s:string;delimiter:string):string;
  var
    p:Integer;
  begin
    p:=Pos(delimiter,s);
    if p=0 then Result:=s
    else Result:=Copy(s,1,p-1);
    s:=Copy(s,Length(Result)+Length(delimiter)+1,Length(s));
  end;

  function GetTokenIndex(s:string;delimiter:string;index:Integer):string;
  var
    i:Integer;
  begin
    Result:='';
    for i:=0 to index do
      Result:=RemoveToken(s,delimiter);
  end;

begin

  if not EditTF then
  begin

    //座標を取得
    i:=Memo2.Perform(EM_LINEFROMCHAR, Memo2.SelStart, 0);

    //エラー対策
    if Memo2.Lines[i]='' then Exit;

    x1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',0));
    y1:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',1));
    x2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',2));
    y2:=StrToInt(GetTokenIndex(Memo2.Lines[i],',',3));

    if Assigned(plImage1) then begin
      FreeAndNil(plImage1);
    end;

    //コンポーネントを生成し,イベントを定義し,位置を指定して画像を表示
    plImage1:=TplResizeImage.Create(Self);
    plImage1.Parent:=ScrollBox1;
    plImage1.TransEvent:=True;
    //クライアント座標をスクリーン座標へ変換
    //GetSystemMetrics(SM_CYCAPTION) -> タイトルバーの高さ
    //GetSystemMetrics(SM_CYFRAME) -> ウィンドウの枠幅
    p1.X:=x1-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1.Y:=y1-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.X:=x2-(GetSystemMetrics(SM_CYFRAME) div 2);
    p2.Y:=y2-GetSystemMetrics(SM_CYCAPTION)-(GetSystemMetrics(SM_CYFRAME) div 2);
    p1:=Image1.ClientToScreen(p1);
    p2:=Image1.ClientToScreen(p2);
    plImage1.SetBounds(p1.X, p1.Y, p2.X-p1.X, p2.Y-p1.Y);

    //SelectedプロパティをTrueにするとラバーバンドとグラブハンドルが表示される
    plImage1.Selected := True;
    plImage1.BringToFront;

  end;

end;

ラバーバンドはMr.XRAYさんのWebサイトにあったplResizeImageを使わせていただいて作成。これまでにもどれだけ助けていただいたことか・・・。このような素晴らしい素材を提供し続けてくださっているMr.XRAYさんに今回も心から感謝申し上げます。

157_移動リサイズ可能な TImage   ラバーバンドとグラブハンドル

http://mrxray.on.coocan.jp/Delphi/plSamples/157_MoveResize_GrabHandle.htm

ラバーバンドで囲まれた矩形が必要な矩形であった場合は、下のMemo3へ「移動」ボタンをクリックしてデータを移す。で、次の矩形をラバーバンドで囲んで表示する。

次の矩形の要・不要を判断
必要な矩形であれば下のMemo3へ移動する

この作業を順次繰り返すと、最終的に必要な矩形の座標のみがMemo3に移動。不要な矩形の座標はすべて削除されることになる。

必要な矩形の座標のみ、採点順に取得できた!

最終的に過不足がないか・どうか、Memo3の先頭座標データをクリック、ラバーバンドで該当矩形を囲んで表示、下向きの矢印キーを次へ次へと押して、フォーカスを下の座標データへ移動、ラバーバンドを表示して確認、これを最後の座標データまで繰り返し。

採点順を含めて、必要な座標データがすべて揃っていることを先頭データから順に確認する。

必要な座標がすべて取得できていることを確認したら、「保存」ボタンをクリックして手書き答案採点ソフトが実行時に読み込む、様々な採点設定を記録するための iniファイルに解答欄の座標データを保存する。

データの保存

【任意の範囲を指定したい場合】

複数の解答欄を抱き合わせて、完全解答で正解としたい場合などに対応するため、任意の範囲を矩形選択できるようにした。

画面中央左の追加ボタンをクリックすると、画面の中央にラバーバンドが表示される。これを任意の位置へドラッグする。

追加ボタンをクリックしてラバーバンドを表示
画面の中央にラバーバンドを表示、これを任意の位置へドラッグ。

ボタンのCaptionは、自動で「取得」に変更。

ボタンのCaptionを変更

任意の範囲をラバーバンドで囲んだら(=範囲指定完了)、「取得」ボタンをクリック。取得された座標がボタンの右のEditに表示され、同時にクリップボードへ送られる。

任意の範囲を指定して座標を取得

Memo3上の「追加」ボタンをクリックすると、Memo3が編集可能になるので、採点順を確認して、適切な行に座標のデータを追加(クリップボードから貼り付けても、データを見ながら手動入力してもよい)。

適切な位置に座標のデータを入力する

ラバーバンドを使わなくても、解答欄の左上と右下を、それぞれポイントすればその座標をラベルに表示する機能も追加したので、上の図のように、Memo3を編集モードにして、座標を任意の行へ直接入力することも可能。

マウスでポイントした場所の座標をリアルタイムで表示する

クライアント座標の取得と表示を行う手続きは、次の通り。

procedure TForm1.Image1MouseMove(Sender: TObject; Shift: TShiftState; X,
  Y: Integer);
var
  PtInput:TPoint;
begin
  //スクリーン座標を取得
  GetCursorPos(PtInput);
  //で、そのコントロールのクライアント領域に対するカーソルの座標を取得
  PtInput := Image1.ScreenToClient(PtInput);

  //補正する必要はない(確認済み)
  //表示
  Label2.Caption:=
    Format(' クライアント座標  '+'X : %d, Y : %d', [PtInput.X, PtInput.Y]);
end;

【矢印キーの押し下げを拾う】

最も難しかったのが、フォーカスが「どこにあるか」で矢印キーの挙動を制御すること。以前にStringGridのセルのフォーカスの移動を制限した時に学んだ内容が今回も役に立った。

今回は、Memoにフォーカスがある場合と、ラバーバンドにフォーカスがある場合、さらにラバーバンドにフォーカスがある場合のうち、Shiftキーと同時に矢印キーが押し下げられているのか(=ラバーバンドの大きさの変更)、それとも矢印キーが単独で押し下げられているのか(=ラバーバンドの表示位置の移動)、この3パターンを見分けてそれぞれにあった動作を行わせたいと考えた。最終的には次のコードで対応。

  private
    { Private 宣言 }

    //ある(矢印他)キーが押されたことを知る
    procedure AppMessage(var Msg: TMsg; var Handled: Boolean);

上のように手続きを宣言して、Shift+Ctrl+Cで手続きを生成。

procedure TForm1.AppMessage(var Msg: TMsg; var Handled: Boolean);
var
  StrText: string;
begin
  //何かキーが押し下げられたら
  if Msg.message = WM_KEYDOWN then
  begin
    try
      if ActiveControl is TMemo then
      begin
        //キー操作を「通常動作」にするおまじない
        case Msg.Message of
          WM_USER + $0500:
          Handled := True;
        end;
      end else begin
        //上位ビットが1ならShiftキーが押されている
        if GetKeyState(VK_SHIFT) and $8000 <> 0 then
        begin
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Width := plImage1.Width + 1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Width := plImage1.Width - 1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Height := plImage1.Height - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Height := plImage1.Height + 1;
              Msg.wParam:=0;
            end;
          end;
        end else begin
          //Shiftキーは押されていない
          //対象を限定(どちらでも動いた)
          //if TplResizeImage(ActiveControl).Visible then
          if plImage1.Visible then
          begin
            //右矢印キー
            if Msg.wParam=VK_RIGHT then
            begin
              plImage1.Left := plImage1.Left +1;
              Msg.wParam:=0;
            end;
            //左矢印キー
            if Msg.wParam=VK_LEFT then
            begin
              plImage1.Left := plImage1.Left -1;
              Msg.wParam:=0;
            end;
            //上矢印キー
            if Msg.wParam=VK_UP then
            begin
              plImage1.Top := plImage1.Top - 1;
              Msg.wParam:=0;
            end;
            //下矢印キー
            if Msg.wParam=VK_DOWN then
            begin
              plImage1.Top := plImage1.Top + 1;
              Msg.wParam:=0;
            end;
            //Deleteキー
            if Msg.wParam=VK_DELETE then
            begin
              //plImage1を解放
              if Assigned(plImage1) then begin
                FreeAndNil(plImage1);
              end;
              Msg.wParam:=0;
            end;
          end;
        end;
      end;
    except
      on E: Exception do
      begin
        StrText := E.ClassName + sLineBreak + E.Message;
        Application.MessageBox(PChar(StrText), '情報', MB_ICONINFORMATION);
      end;
    end;
  end;
end;

plImage1が生成されないうちに上の手続きが呼ばれると、当然、一般保護違反のエラーが発生するので、FormCreate時にplImage1を生成しておく。

procedure TForm1.FormCreate(Sender: TObject);
var
  //Python39-32へのPath
  AppDataDir:string;
  i:integer;
begin

  //メモリーリークがあれば検出
  ReportMemoryLeaksOnShutdown:=True;

  //有効にする(忘れないこと!)
  Application.OnMessage := AppMessage;

  //[Enter]でコントロールを移動させるために、Form上のコンポーネント
  //より先にFormがキーボードイベントを取得する。
  KeyPreview:=True;

  //コンポーネントを生成 -> インスタンス(実体)をつくる
  // = 一般保護違反エラーの防止
  //plImage1はグローバル変数として宣言しているから未定義の識別子エラーは発生しない
  //でも、Create(生成)してからでなければ使えない!
  plImage1:=TplResizeImage.Create(Self);

  //編集フラグ(編集中ではない)
  EditTF:=False;
  PlusTF:=False;
  Memo2.ReadOnly:=True;

  //StatusBar1の設定
  StatusBar1.SimplePanel:=True;

  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;

  //Embeddable Pythonの存在の有無を調査
  AppDataDir:=ExtractFilePath(Application.ExeName)+'Python39-32';
  if DirectoryExists(AppDataDir) then
  begin
    //フォルダが存在したときの処理    
    PythonEngine1.AutoLoad:=True;
    PythonEngine1.IO:=PythonGUIInputOutput1;
    PythonEngine1.DllPath:=AppDataDir;
    PythonEngine1.SetPythonHome(PythonEngine1.DllPath);
    PythonEngine1.LoadDll;
    //PythonDelphiVar1のOnSeDataイベントを利用する
    PythonDelphiVar1.Engine:=PythonEngine1;
    PythonDelphiVar1.VarName:=AnsiString('var1');  //プロパティで直接指定済み
    //初期化
    PythonEngine1.Py_Initialize;
  end else begin    
    PythonEngine1.AutoLoad:=False;
  end;

  //面積の閾値の選択肢を設定
  for i := 1 to 200 do
  begin
    cmbThreshold.Items.Add(IntToStr(i*100));
  end;

  //画面のちらつきを防止する
  DoubleBuffered := True;

end;

で、メモリーリーク発生の原因とならないよう、アプリの終了時に忘れずに解放。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
  //メモリーリークを防止する
  PythonEngine1.Py_Finalize;
  PythonDelphiVar1.Finalize;
  FreeAndNil(plImage1);
end;

5.まとめ

(1)矩形の検出は、OpenCVのfindContours関数を利用する。
(2)矩形の検出を回避するには「点線」を利用する。
(3)GUIはDelphiで作成し、必要な座標だけ保存できるように工夫。
(4)「フォーカスがどこにあるか」で矢印キーの動作を制御。
(5)コントロール生成のタイミングと確実な破棄にも注意する。

6.お願いとお断り

このサイトの内容を利用される場合は、自己責任でお願いします。記載した内容を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。