目次
始めた理由
ある日知人から、「国語のテストを自動で採点してくれるアプリ作れない?」という相談を受けました。話を聞いてみると、塾で国語のテストを実施するそうなのですが、その際に採点がとても大変だとのこと。自動で採点をしてくれるサービスもすでにあるそうですが、料金が高いのと使い勝手が悪いという評判だそうで、知り合いに頼めるなら頼んでみようとのことで、私に話が来ました。作れる自信があったわけではないですが、一応やってみるかという気持ちで引き受けました。そんなわけで、Pythonを初めて1年にも満たない初学者が、いろいろ調べながら頑張って採点アプリを作りました。その記録をこちらに載せていこうと思います。どなたかの参考になりましたら、幸いです。
環境説明
・Windows11でVSCODEを使ってコード編集
・PythonはRyeを使って仮想環境を構築
(参考:https://rye.astral.sh/guide/installation/)
・Python:3.11.9を使用
・google cloud アカウントを作成
(新規登録であれば、90 日間 $300 分の無料トライアル期間があります)
・GitHubアカウント作成、アプリ用のリポジトリ作成
・コードの作成にはchatgpt君に助けてもらいました
どうやってpythonだけでアプリを作成するのか
streamlitという素敵なライブラリ
Streamlitは、データサイエンティストや機械学習エンジニア向けに設計されたPythonフレームワークで、インタラクティブなデータアプリケーションを迅速に構築するためのツールです
pythonだけで開発が完結して、htmlとかも編集する必要がなく初心者でも扱いやすいです
これは実務で扱っていたので、特に迷いもなくstreamlitでアプリを作成しようと決めました
Flaskとかのライブラリを使って、きちんと自分で設計してアプリを作ることも考えたのですが、flaskなどのwebアプリフレームに馴染みがあまりなかったのと、そこまでアプリ画面をこだわることもないなと思ったので、streamlitで作ることにしました。
どういう構成でアプリを作るのか考える
想定として、利用者が以下の画像ファイルを持っているとして構成を考えました
・白紙の回答用紙
・模範解答用紙
・点数が解答欄に書かれた回答用紙
・生徒の回答用紙
これらを入力として、最終的にそれぞれの生徒の点数を出力できるようなシステムの構成を考えました。
すごく適当な図で申し訳ないですが、大体このような形でアプリを作ろうと思いました。
黒矢印→がユーザーからの入力で、青線部分がすべてアプリの中での、挙動になります。
ここからはそれぞれの機能について、実装したコードを載せながら、解説していこうと思います。
アプリ機能① =画像のゆがみ補正=
構成図を見ると、まず初めにユーザーは3種類の回答用紙の画像をアプリに読み込ませるようしてあります。この読み込ませた3種類の画像の大きさ・水平度がそろっていないと、回答欄検出して模範解答・点数を検出する機能で誤った情報を検出してしまうので、それら防ぐための補正する機能になります。
機能としては
①:画像の縦横をそろえる
②:回答用紙の縦線・横線から画像を水平にする
③:②で水平にした画像の周りにある黒い部分をクロップする
④:画像の大きさをそろえる
⑤:補正によって生まれた不要な周りの線を削除する
⑥:回答欄の位置をそろえる
以上の6種類の機能を実装しました。それぞれのコードとやっていることを紹介します。
①画像の縦横をそろえる
#画像が回転している場合は直す
if np.array(white_image).shape[0] > np.array(white_image).shape[1]:
white_img_org = np.array(white_image.rotate(-90, expand=True))
else:
white_img_org = np.array(white_image)
if np.array(answer_image).shape[0] > np.array(answer_image).shape[1]:
answer_img_org = np.array(answer_image.rotate(-90, expand=True))
else:
answer_img_org = np.array(answer_image)
if np.array(score_image).shape[0] > np.array(score_image).shape[1]:
score_img_org = np.array(score_image.rotate(-90, expand=True))
else:
score_img_org = np.array(score_image)
white_image : 白紙の回答用紙
answer_image : 模範回答用紙
score_image : 点数の書かれた回答用紙
画像をnp.arrayに変換することで、縦が横よりも長い場合は画像を-90度反転するようにしています。
これは国語の回答用紙が横に長い形をしているため、そこに3種類の画像をそろえるようにするためです。
②回答用紙の縦線・横線から画像を水平にする
def detect_representative_line(image, is_vertical):
# グレースケールに変換
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# エッジ検出
edges = cv2.Canny(gray, 50, 150, apertureSize=3)
# Hough変換で直線検出
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=100, maxLineGap=10)
if lines is None:
return None
# 代表的な直線を見つける
representative_line = None
max_len = 0
for line in lines:
for x1, y1, x2, y2 in line:
length = np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi
if is_vertical:
if 80 < abs(angle) < 100: # ほぼ垂直の線を選択
if length > max_len:
max_len = length
representative_line = (x1, y1, x2, y2)
else:
if -10 < abs(angle) < 10: # ほぼ水平の線を選択
if length > max_len:
max_len = length
representative_line = (x1, y1, x2, y2)
return representative_line
def calculate_rotation_angle(line, is_vertical):
if line is None:
return 0
x1, y1, x2, y2 = line
angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi
if is_vertical:
if angle > 0:
angle -= 90
else:
angle += 90
return angle
def adjust_image_angle(image, angle):
(h, w) = image.shape[:2]
center = (w // 2, h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
rotated = cv2.warpAffine(image, M, (w, h))
return rotated
def process_image(image, is_vertical):
line = detect_representative_line(image, is_vertical)
angle = calculate_rotation_angle(line, is_vertical)
adjusted_image = adjust_image_angle(image, angle)
return adjusted_image
# 縦線と横線で画像を調整
adjusted_white = utils.process_image(white_img_org, is_vertical=False)
adjusted_answer = utils.process_image(answer_img_org, is_vertical=False)
adjusted_score = utils.process_image(score_img_org, is_vertical=False)
やっていることとしては
ハフ変換で直線の検出
↓
縦線、横線の中で一番長い線だけを選び、その線の角度を算出
↓
アフィン変換を使って、画像を水平に補正
このような流れで、画像を水平に補正しています。
③水平にした画像の周りにある黒い部分をクロップする
def remove_black_borders(image):
# グレースケールに変換
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
# 二値化して黒い部分をマスク
_, thresh = cv2.threshold(gray, 1, 255, cv2.THRESH_BINARY)
# 輪郭を検出して最大のものを取得
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnt = max(contours, key=cv2.contourArea)
# バウンディングボックスを計算
x, y, w, h = cv2.boundingRect(cnt)
# クロップ
cropped_image = image[y:y+h, x:x+w]
return cropped_image
# 黒い部分を削除してクロップ
cropped_white = utils.remove_black_borders(adjusted_white)
cropped_answer = utils.remove_black_borders(adjusted_answer)
cropped_score = utils.remove_black_borders(adjusted_score)
画像が曲がっていたのを水平に直した場合、アフィン変換で戻すと、画像のサイズは元の曲がったまま、角度だけ水平になるので画像の周りに黒い部分ができてしまいます。それを削除する工程です。
画像から輪郭検出をして、その中で最大の大きさのものを選んで、そこに画像を合わせています。
# 輪郭を検出して最大のものを取得
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnt = max(contours, key=cv2.contourArea)
輪郭検出はこのコードで簡単にできるのが非常にありがたかったです。結構精度高く輪郭を検出してくれます。(直線以外も検出可能です。)
④画像の大きさをそろえる
def resize_to_original_size(cropped_image, original_size):
# リサイズ
resized_image = cv2.resize(cropped_image, (original_size[1], original_size[0]))
return resized_image
# 元のサイズにリサイズしながら、画像のサイズをそろえる
original_size = white_img_org.shape[:2]
resized_white = utils.resize_to_original_size(cropped_white, original_size)
resized_answer = utils.resize_to_original_size(cropped_answer, original_size)
resized_score = utils.resize_to_original_size(cropped_score, original_size)
cv2.resizeを使用して画像のサイズを白紙の回答用紙にそろえています。
⑤補正によって生まれた不要な周りの線を削除する
def remove_line_around(resized_image1):
#画像の周囲の線を消す処理
resized_image1[0:20,:,:] =255
resized_image1[-20:-1,:,:] =255
resized_image1[:,0:20,:] =255
resized_image1[:,-20:-1,:] =255
return resized_image1
#画像の周囲の線を消す処理
resized_white = utils.remove_line_around(resized_white)
resized_answer = utils.remove_line_around(resized_answer)
resized_score = utils.remove_line_around(resized_score)
画像をリサイズしたときに出てくる画像の周囲の黒い線を消す処理を行っています。
画像の周囲20ピクセルを白色にする処理をしています。(この処理は想定される回答用紙の周りが白色であるという想定に基づいているので、回答用紙によっては変更する必要があるかもしれません)
⑥回答欄の位置をそろえる
def align_images(image1, image2):
bottom_line1, left_line1 = detect_lines(image1)
bottom_line2, left_line2 = detect_lines(image2)
# Y方向の位置合わせ
y1 = max(bottom_line1[1], bottom_line1[3])
y2 = max(bottom_line2[1], bottom_line2[3])
dy = y1 - y2
height, width, channels = image2.shape
aligned_image2_y = np.zeros_like(image2)
if dy > 0:
aligned_image2_y[dy:height, :] = image2[0:height-dy, :]
aligned_image2_y[0:dy,:] = 255
else:
aligned_image2_y[-dy:height, :] = image2[0:height+dy, :]
aligned_image2_y[0:-dy,:] = 255
# X方向の位置合わせ
x1 = min(left_line1[0], left_line1[2])
x2 = min(left_line2[0], left_line2[2])
dx = x2 - x1
aligned_image2 = np.zeros_like(aligned_image2_y)
if dx > 0:
aligned_image2[:, 0:width-dx] = aligned_image2_y[:, dx:width]
aligned_image2[:, width-dx:width+dx] = 255
else:
aligned_image2[:, 0:width+dx] = aligned_image2_y[:, -dx:width]
aligned_image2[:, width+dx:width-dx] = 255
return aligned_image2
# 画像の位置補正
white_img_complete = resized_white
answer_img_complete = utils.align_images(resized_white, resized_answer)
score_img_complete = utils.align_images(resized_white, resized_score)
ここでやっていることは、
2つの画像から直線を検出し、その中で一番下の線と一番左の線を検出します
↓
その線をもとに2つの画像の回答欄を同じ位置に補正します
この処理によって白紙の回答用紙・模範回答用紙・点数の書かれた回答用紙の回答欄の位置が同じになったので、これで白紙の回答用紙からそれぞれの設問の回答欄を検出することで、その位置にある模範解答・点数を読み取ることで、それぞれの設問に対する情報をそろえることができます。
終わり
いったんここまでで区切ろうと思います。
次回以降も機能について解説していくつもりです。