初心者がstreamlitを使って採点アプリを作ってみた#1

<!-- 目次部分(リンクになるところ) -->
# 目次
1. [始めた理由](#anchor1)
1. [環境説明](#anchor2)
1. [どうやってpythonだけでアプリを作成するのか](#anchor3)
1. [どういう構成でアプリを作るのか考える](#anchor4)
1. [アプリ機能①　＝画像のゆがみ補正＝](#anchor5)
1. [終わり](#anchor6)

<a id="anchor1"></a>
# 始めた理由
ある日知人から、「国語のテストを自動で採点してくれるアプリ作れない？」という相談を受けました。話を聞いてみると、塾で国語のテストを実施するそうなのですが、その際に採点がとても大変だとのこと。自動で採点をしてくれるサービスもすでにあるそうですが、料金が高いのと使い勝手が悪いという評判だそうで、知り合いに頼めるなら頼んでみようとのことで、私に話が来ました。作れる自信があったわけではないですが、一応やってみるかという気持ちで引き受けました。そんなわけで、Pythonを初めて1年にも満たない初学者が、いろいろ調べながら頑張って採点アプリを作りました。その記録をこちらに載せていこうと思います。どなたかの参考になりましたら、幸いです。

<a id="anchor2"></a>
# 環境説明
・Windows11でVSCODEを使ってコード編集
・PythonはRyeを使って仮想環境を構築
（参考：https://rye.astral.sh/guide/installation/）
・Python:3.11.9を使用
・google cloud アカウントを作成
（新規登録であれば、90 日間 $300 分の無料トライアル期間があります）
・GitHubアカウント作成、アプリ用のリポジトリ作成
・コードの作成にはchatgpt君に助けてもらいました

<a id="anchor3"></a>
# どうやってpythonだけでアプリを作成するのか
### streamlitという素敵なライブラリ
Streamlitは、データサイエンティストや機械学習エンジニア向けに設計されたPythonフレームワークで、インタラクティブなデータアプリケーションを迅速に構築するためのツールです
pythonだけで開発が完結して、htmlとかも編集する必要がなく初心者でも扱いやすいです

これは実務で扱っていたので、特に迷いもなくstreamlitでアプリを作成しようと決めました
Flaskとかのライブラリを使って、きちんと自分で設計してアプリを作ることも考えたのですが、flaskなどのwebアプリフレームに馴染みがあまりなかったのと、そこまでアプリ画面をこだわることもないなと思ったので、streamlitで作ることにしました。

<a id="anchor4"></a>
# どういう構成でアプリを作るのか考える
想定として、利用者が以下の画像ファイルを持っているとして構成を考えました
・白紙の回答用紙
・模範解答用紙
・点数が解答欄に書かれた回答用紙
・生徒の回答用紙

これらを入力として、最終的にそれぞれの生徒の点数を出力できるようなシステムの構成を考えました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821078/62a8f101-668e-21a8-4eb6-a02ebee17c33.png)

###
すごく適当な図で申し訳ないですが、大体このような形でアプリを作ろうと思いました。
黒矢印→がユーザーからの入力で、青線部分がすべてアプリの中での、挙動になります。

ここからはそれぞれの機能について、実装したコードを載せながら、解説していこうと思います。

<a id="anchor5"></a>
# アプリ機能①　＝画像のゆがみ補正＝
構成図を見ると、まず初めにユーザーは3種類の回答用紙の画像をアプリに読み込ませるようしてあります。この読み込ませた3種類の画像の大きさ・水平度がそろっていないと、回答欄検出して模範解答・点数を検出する機能で誤った情報を検出してしまうので、それら防ぐための補正する機能になります。

機能としては
①：画像の縦横をそろえる
②：回答用紙の縦線・横線から画像を水平にする
③：②で水平にした画像の周りにある黒い部分をクロップする
④：画像の大きさをそろえる
⑤：補正によって生まれた不要な周りの線を削除する
⑥：回答欄の位置をそろえる

以上の6種類の機能を実装しました。それぞれのコードとやっていることを紹介します。

### ①画像の縦横をそろえる
#画像が回転している場合は直す
```php
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種類の画像をそろえるようにするためです。



### ②回答用紙の縦線・横線から画像を水平にする
```php
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)
```
やっていることとしては
ハフ変換で直線の検出
　　　　　↓
縦線、横線の中で一番長い線だけを選び、その線の角度を算出
　　　　　↓
アフィン変換を使って、画像を水平に補正

このような流れで、画像を水平に補正しています。

### ③水平にした画像の周りにある黒い部分をクロップする
```php
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)
```
画像が曲がっていたのを水平に直した場合、アフィン変換で戻すと、画像のサイズは元の曲がったまま、角度だけ水平になるので画像の周りに黒い部分ができてしまいます。それを削除する工程です。
画像から輪郭検出をして、その中で最大の大きさのものを選んで、そこに画像を合わせています。

```php
# 輪郭を検出して最大のものを取得
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cnt = max(contours, key=cv2.contourArea)
```
輪郭検出はこのコードで簡単にできるのが非常にありがたかったです。結構精度高く輪郭を検出してくれます。（直線以外も検出可能です。）

### ④画像の大きさをそろえる
```php
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を使用して画像のサイズを白紙の回答用紙にそろえています。

### ⑤補正によって生まれた不要な周りの線を削除する
```php
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ピクセルを白色にする処理をしています。（この処理は想定される回答用紙の周りが白色であるという想定に基づいているので、回答用紙によっては変更する必要があるかもしれません）

### ⑥回答欄の位置をそろえる
```php
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つの画像の回答欄を同じ位置に補正します

この処理によって白紙の回答用紙・模範回答用紙・点数の書かれた回答用紙の回答欄の位置が同じになったので、これで白紙の回答用紙からそれぞれの設問の回答欄を検出することで、その位置にある模範解答・点数を読み取ることで、それぞれの設問に対する情報をそろえることができます。

<a id="anchor6"></a>
# 終わり
いったんここまでで区切ろうと思います。
次回以降も機能について解説していくつもりです。