目次
- はじめに
- アプリ機能② =回答欄の枠を検出=
- アプリ機能③ =模範解答・点数を読み取る=
- アプリ機能④ =読み取った模範解答・点数を修正=
- アプリ機能⑤ =回答欄ごとの生徒の回答を読み取る=
- アプリ機能⑥ =読み取った生徒の回答を修正=
- 終わりに
はじめに
streamlitを使って、採点アプリを作ってみた記録の第2回目の投稿になります。
前回までの記事が気になった方は
https://qiita.com/0dn09g3y726519/items/d78397d3c174f4388687
こちらをご覧ください。
下図のような構成のアプリを作っています。
アプリ機能①の画像のゆがみの補正部分は前回の記事で話したので、続きから機能紹介をしようと思います。
アプリ機能② =回答欄の枠を検出=
def remove_lines(image):
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 40))
detect_vertical = cv2.morphologyEx(gray, cv2.MORPH_OPEN, vertical_kernel, iterations=2)
cnts = cv2.findContours(detect_vertical, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
for c in cnts:
cv2.drawContours(gray, [c], -1, (255, 255, 255), 5)
horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1))
detect_horizontal = cv2.morphologyEx(gray, cv2.MORPH_OPEN, horizontal_kernel, iterations=2)
cnts = cv2.findContours(detect_horizontal, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
for c in cnts:
cv2.drawContours(gray, [c], -1, (255, 255, 255), 5)
result = cv2.GaussianBlur(gray, (5, 5), 0)
_, result = cv2.threshold(result, 150, 255, cv2.THRESH_BINARY_INV)
return result
thresh = utils.remove_lines(white_img_complete)
contours, _ = cv2.findContours(thresh, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
valid_contours = []
for i, cnt1 in enumerate(contours):
overlap = False
for j, cnt2 in enumerate(contours):
if i != j and 2000 < cv2.contourArea(cnt1) < 700000 and 2000 < cv2.contourArea(cnt2) < 700000:
# Calculate overlap area
overlap_area = utils.rectangles_overlap_area(cnt1, cnt2)
if overlap_area > 10000:
overlap = True
if cv2.contourArea(cnt1) > cv2.contourArea(cnt2):
valid_contours.append(cnt1)
if not overlap:
valid_contours.append(cnt1)
このコードで白紙の回答用紙から点線を削除して、回答欄を検出しています。
まず、remove_lines関数を用いて、画像内の点線を削除しています。点線を削除する理由としては、回答欄を検出する際に、回答欄を誤認してしまうからです。
(点線で回答欄を分ける場合は、この関数はいらないのですが、今回は国語のテストで長文の回答欄に点線があり、点線を消す必要がありました。)
点線の削除手順
点線の検出に用いるカーネルを作成(点線を検出専用のカーネル)
↓
モルフォロジー操作で画像内の点線を、水平・垂直ごとに検出
↓
検出した点線を白色で塗りつぶします
↓
ガウシアンブラーを用いて画像のノイズを減少させます
↓
2値化をした画像を返す
このような手順で点線を検出して、削除します。その後、輪郭検出を行い回答欄の枠を検出します。
検出した枠の中で面積が一定以上かつ、枠がかぶっていない・枠がかぶっている面積が多いほうを回答欄の枠として保存します。
これで白紙の回答用紙から回答欄の枠が検出できました。
ここから模範解答画像を用いて解答テキスト情報と点数情報を読み取る工程に移るのですが、ここで画像から文字情報を読むOCRが必要になりますが、PythonではライブラリとしてOCRが二つ実装されています。また、APIを使用すればほかにも様々なOCRが利用できます。その中で、どれが今回のアプリに最も適しているのかを以下のようなコードで確かめました。
# 画像の読み込み
image = Image.open('OCRテスト用画像.jpg')
# Google Cloud Vision API
def google_vision_ocr(image):
# 画像をバイト列に変換
_, buffer = cv2.imencode('.jpg', np.array(image.rotate(-90,expand=True)))
image_content = buffer.tobytes()
client = vision.ImageAnnotatorClient()
image = vision.Image(content=image_content)
image_context = vision.ImageContext(
text_detection_params=vision.TextDetectionParams(
enable_text_detection_confidence_score=True,
)
)
response = client.text_detection(image=image, image_context=image_context)
texts = response.text_annotations
output_text = texts[0].description # 最初の検出結果のみ抽出
return output_text
# Tesseract OCR
def tesseract_ocr(image):
return pytesseract.image_to_string(image, config='--oem 3', lang='jpn+eng')
# EasyOCR
def easyocr_ocr(cropped_image):
reader = easyocr.Reader(['ja', 'en'])
result = reader.readtext(cropped_image)
output_text = ''
for detection in result:
output_text += detection[1] + '\n'
output_text = result[0][1]
return output_text
OCRとしてPythonライブラリにあるPytesseract, EasyOCRを使用しました。これらはpip installするだけで、使用できます。APIを利用してGoogleが出しているCloud VisionというOCRを利用しました。このAPIの利用のためには、Google CLoudでのアカウント開設をして設定をする必要があります。いろいろとOCRについて調べているうちに、このcloud visionの精度が高いということだったので、利用してみました。検出精度の実験のために、簡単な手書き文字を書いて用意しました。
OCR精度評価用画像①
OCR精度評価用画像②
OCRの比較、手書き文字の認識、縦書き文字の認識にどこまで対応できるのかを見る目的で画像を用意しました。
文字も汚いですしすごく暗い画像になってしまいました。
(きちんと評価するにはもっとちゃんとした画像を用意したほうがいいですね)
評価結果が以下になります。
OCR結果
Google Cloud Vision API OCR Result:
はない
カという文字
د و
Tesseract OCR Result:
EasyOCR Result:
#:
Google Cloud Vision API OCR Result:
あ ア
Tesseract OCR Result:
EasyOCR Result:
ス.
評価結果を見るとどれも完璧に認識はできていませんでした。これは私の用意した画像が暗すぎるせいかもしれないです。google cloud visionが一番認識精度としては高そうなことがわかりました。こんな適当な画像でも、結構認識してくれてますね。今回はGoogle CLoud Visionを使用することに決めました。
Google CLoud VisionのAPI利用料は以下の画像です。
ユニット=1枚の画像という認識です。なので、ひと月1000枚までは無料でOCR実行できるようです。
余談:ChatGPTでのOCR
余談ですが、ChatGPT4oにこの画像に書いてある文字を認識してくださいといったところ以下のように教えてくれました。
「以下の画像に書かれている手書き文字は次の通りです。
画像1:
その心に
力という文字
はない
画像2:
6 あ 了」
カタカナのアを了と間違えていますが、ほかは完璧ですね。特に「力という文字」この部分の漢字の力をカタカナのカと間違えてないところを見ると、文脈も考慮できてそうだなと思いました。
おそらくChatGPTを使用するのが、現状最もOCRでは正確かなと思います。あとは値段と速度との相談ですね。今回はテスト用紙内の画像を細かくOCRかけていく予定だったので、ChatGPTの利用はあきらめました。
(ChatGPTのAPI利用料は入力(1,000トークンあたり): 0.005ドル 出力(1,000トークンあたり): 0.015ドル + ChatGPT+の月額20ドル)
アプリ機能③ =模範解答・点数を読み取る=
def detect_text(image_content):
image2 = vision.Image(content=image_content)
image_context = vision.ImageContext(
text_detection_params=vision.TextDetectionParams(
enable_text_detection_confidence_score=True,
)
)
response = client.document_text_detection(
image=image2,
image_context={'language_hints': ['ja', 'en', 'ja_vert', 'en_vert']}
)
return response
def extract_text_from_roi(ocr_response, x, y, w, h):
extracted_text = ""
for page in ocr_response.full_text_annotation.pages:
for block in page.blocks:
for paragraph in block.paragraphs:
for word in paragraph.words:
for symbol in word.symbols:
bbox = symbol.bounding_box
if (x <= bbox.vertices[0].x <= x+w) and (y <= bbox.vertices[0].y <= y+h):
extracted_text += symbol.text
return extracted_text
_, answer_buffer = cv2.imencode('.jpg', answer_img_complete)
_, score_buffer = cv2.imencode('.jpg', score_img_complete)
answer_image_content = answer_buffer.tobytes()
score_image_content = score_buffer.tobytes()
answer_ocr_response = utils.detect_text(answer_image_content)
score_ocr_response = utils.detect_text(score_image_content)
for i, cnt in enumerate(valid_contours):
area = cv2.contourArea(cnt)
if 2000 < area < 700000:
x, y, w, h = cv2.boundingRect(cnt)
answer_roi = answer_img_complete[y-6:y+h, x-2:x+w+2]
score_roi = score_img_complete[y-6:y+h, x-2:x+w+2]
try:
answer_ocr_result = utils.extract_text_from_roi(answer_ocr_response, x-2, y-6, w+2, h).replace('\n', '')
score_ocr_result = utils.extract_text_from_roi(score_ocr_response, x-2, y-6, w+2, h).replace('\n', '')
except Exception as e:
st.error(f"エラーが発生しました: {str(e)}")
data_entry = {
'xywh': (x, y, w, h),
'answer_ocr': answer_ocr_result,
'score_ocr': score_ocr_result,
'answer_roi': answer_roi,
'score_roi': score_roi,
'question_number': question_number
}
if len(answer_ocr_result) >= 50:
excluded_data.append(data_entry)
else:
question_data.append(data_entry)
このコードで模範解答用紙の画像と白紙の回答用紙に点数が書かれた画像を読み込んで、それぞれの設問ごとの模範解答と点数を保存していきます。OCRに英語も指定しているのは数字が読み込めるようにするためです。設問ごとに画像を切り出して、OCRを実行する方法でもよかったのですが、それだと利用料が高くなるので、一枚の画像のOCR結果から設問ごとのOCR結果を切り出して保存するようにしています。一枚の画像すべてのOCR結果をextract_text_from_roiに入力して、設問ごとの座標から必要なOCR結果を切り出せるようにしています。
設問ごとのOCR結果はdata_entryという変数に保存することでまとめています。
最後にOCR結果のテキスト情報が50文字を超えたら長文問題だと、認識してあとでLLMに採点してもらうために、別の変数に保存しています。以上で入力画像から情報を読み取る工程は終了になります。ここから読み取った情報を人の手で修正する工程に入ります。この工程はないほうが理想ですが、OCRの認識精度も完ぺきではないので、必要になっていきます。
アプリ機能④ =読み取った模範解答・点数を修正=
question_data = st.session_state.question_data
num_columns = 5 # 1行に並べる画像の数
image_height = 200 # 画像の高さを指定します
def update_text_area(index, key, value):
st.session_state[key] = value
question_data[index][key] = value
for i in range(0, len(question_data), num_columns):
cols = st.columns(num_columns)
for j in range(num_columns):
index = i + j
if index < len(question_data):
question = question_data[index]
# 模範解答画像と点数画像を取得
answer_roi = np.array(question['answer_roi'])
score_roi = np.array(question['score_roi'])
# アスペクト比を保持して画像のサイズを変更
answer_pil = Image.fromarray(answer_roi)
score_pil = Image.fromarray(score_roi)
aspect_ratio_answer = answer_pil.width / answer_pil.height
aspect_ratio_score = score_pil.width / score_pil.height
image_width_answer = int(image_height * aspect_ratio_answer)
image_width_score = int(image_height * aspect_ratio_score)
answer_resized = answer_pil.resize((image_width_answer, image_height))
score_resized = score_pil.resize((image_width_score, image_height))
# パディングを追加して高さを統一
if image_width_answer < image_height:
padding = (image_height - image_width_answer) // 2
answer_padded = ImageOps.expand(answer_resized, (padding, 0, padding, 0), fill='white')
else:
answer_padded = answer_resized
if image_width_score < image_height:
padding = (image_height - image_width_score) // 2
score_padded = ImageOps.expand(score_resized, (padding, 0, padding, 0), fill='white')
else:
score_padded = score_resized
# 画像を表示
cols[j].image(answer_padded, caption=f'模範解答 {index+1}', use_column_width=True)
cols[j].image(score_padded, caption=f'点数 {index+1}', use_column_width=True)
# テキストエリアの値をセッションステートに保存
answer_key = f'answer_ocr_{index}'
score_key = f'score_ocr_{index}'
if answer_key not in st.session_state:
st.session_state[answer_key] = question['answer_ocr']
if score_key not in st.session_state:
st.session_state[score_key] = question['score_ocr']
answer_ocr = cols[j].text_area(f'OCR結果 (模範解答) {index+1}',
st.session_state[answer_key],
key=answer_key,
on_change=update_text_area,
args=(index, 'answer_ocr', st.session_state[answer_key]))
score_ocr = cols[j].text_area(f'OCR結果 (点数) {index+1}',
st.session_state[score_key],
key=score_key,
on_change=update_text_area,
args=(index, 'score_ocr', st.session_state[score_key]))
# 保存ボタン
if st.button('変更内容を保存'):
filtered_question_data = []
excluded_question_data = []
for num, question in enumerate(question_data):
question['answer_ocr'] = st.session_state[f'answer_ocr_{num}'].replace('\n', '')
question['score_ocr'] = st.session_state[f'score_ocr_{num}'].replace('\n', '')
if question['answer_ocr'] in ["氏名", "組番"]:
excluded_question_data.append(question)
else:
filtered_question_data.append(question)
st.session_state.question_data = filtered_question_data
st.session_state.excluded_question_data = excluded_question_data
st.success('変更内容が保存されました!')
このコードでは前の段階までで読み取った模範解答と点数が間違っている場合に備えて、人の手で修正できるようにしています。それぞれの読み取るために参照した画像と読み取った結果を表示して、編集できるようにしています。読み取った結果の編集にはst.text_areaで表示+編集できるようにできます。streamlitはすごく簡単にこの辺りが実装できて便利で使いやすいです。
ちなみにアプリのほうの画面では
このように見えています。(適当な画像を読み込んだので、検出結果は散々ですが)
ここで読み取った回答・点数がきちんと修正できましたら、いよいよ生徒の回答を読み取るところです。
ここからはほとんどこれまでのコードを再利用する形で実装しました。
アプリ機能⑤ =回答欄ごとの生徒の回答を読み取る=
uploaded_file = st.file_uploader("生徒の回答画像が入っているフォルダをzipファイルにして、アップロードしてください", type=["zip"])
if uploaded_file is not None:
try:
with ZipFile(uploaded_file, 'r') as zip_ref:
zip_ref.extractall("uploaded_folder")
st.success("ファイルがアップロードされ、展開されました。")
# 展開されたフォルダ内の画像ファイルをリストアップ
image_files = []
for root, dirs, files in os.walk("uploaded_folder"):
for file in files:
if file.lower().endswith(('jpg', 'jpeg', 'png')):
image_files.append(os.path.join(root, file))
if not image_files:
st.warning("フォルダに有効な画像ファイルがありません。")
elif 'question_data' not in st.session_state or 'excluded_question_data' not in st.session_state or 'long_context_data' not in st.session_state:
st.warning("ページ1でOCR処理を行ってください。")
else:
question_data = st.session_state.question_data
excluded_question_data = st.session_state.excluded_question_data
long_context_data = st.session_state.long_context_data
student_data = []
long_context_student_data = []
progress_text = "処理中"
my_bar = st.progress(0, text=progress_text)
progress = 0
total_images = len(image_files)
for idx, image_file in enumerate(image_files):
student_image = Image.open(image_file)
if np.array(student_image).shape[0] > np.array(student_image).shape[1]:
student_img_org = np.array(student_image.rotate(-90, expand=True))
else:
student_img_org = np.array(student_image)
# 縦線と横線で画像を調整
adjusted_student = utils.process_image(student_img_org, is_vertical=False)
# 黒い部分を削除してクロップ
cropped_student = utils.remove_black_borders(adjusted_student)
# 元のサイズにリサイズしながら、画像のサイズをそろえる
original_size = st.session_state['white_img_org'].shape[:2]
resized_student = utils.resize_to_original_size(cropped_student, original_size)
#画像の周囲の線を消す処理
resized_student = utils.remove_line_around(resized_student)
# 画像の位置補正
student_img_complete = utils.align_images(st.session_state['resized_white'], resized_student)
student_info = {'file': image_file, 'answers': [], 'name': '', 'class_number': '', 'long_context':[]}
_, student_buffer = cv2.imencode('.jpg', student_img_complete)
student_image_content = student_buffer.tobytes()
student_ocr_response = utils.detect_text(student_image_content)
# 組番号と名前を取得
for excl_q in excluded_question_data:
x, y, w, h = excl_q['xywh']
try:
student_ocr_result = utils.extract_text_from_roi(student_ocr_response, x-2, y-6, w+2, h).replace('\n', '')
except Exception as e:
st.error(f"エラーが発生しました: {str(e)}")
#student_ocr_result = ""
if '氏名' in excl_q['answer_ocr']:
student_info['name'] = student_ocr_result
# '組番'が含まれているかどうかを確認
elif '組番' in excl_q['answer_ocr']:
# 正規表現を使って「1組1番」の形式を探す
match = re.search(r'(\d+組)(\d+番)', student_ocr_result)
# マッチが見つかった場合、グループに分けて保存
if match:
組 = match.group(1)
番 = match.group(2)
student_info['class'] = 組
student_info['number'] = 番
# 通常の問題を取得
for question in question_data:
x, y, w, h = question['xywh']
student_roi = student_img_complete[y-6:y+h, x-2:x+w+2]
try:
student_ocr_result = utils.extract_text_from_roi(student_ocr_response, x-2, y-6, w+2, h).replace('\n', '')
except Exception as e:
st.error(f"エラーが発生しました: {str(e)}")
# student_ocr_result = ""
if len(question['answer_ocr']) < 50:
student_info['answers'].append({
'question': question['answer_ocr'],
'student_answer': student_ocr_result,
'score': question['score_ocr'],
'xywh': question['xywh'],
'student_roi':student_roi,
'question_number':question['question_number']
})
# 長文の問題を取得
long_context_student_answers = []
for long_q in long_context_data:
x, y, w, h = long_q['xywh']
student_roi = student_img_complete[y-6:y+h, x-2:x+w+2]
try:
student_ocr_result = utils.extract_text_from_roi(student_ocr_response, x-2, y-6, w+2, h).replace('\n', '')
except Exception as e:
st.error(f"エラーが発生しました: {str(e)}")
# student_ocr_result = ""
student_info['long_context'].append({
'question': long_q['answer_ocr'],
'student_answer': student_ocr_result,
'score':long_q['score_ocr'],
'xywh': long_q['xywh'],
'student_roi':student_roi,
'question_number':long_q['question_number']
})
student_data.append(student_info)
生徒の回答用紙は一度にzipファイルでフォルダーごとアップロードしてもらうようにしました。クラスごとのテスト用紙を読み込んでもらう想定です。読み込んだzipファイルから画像を一つずつ取り出し、処理していきます。OCRや画像の位置補正についてはこれまでの機能と同じですので、割愛させていただきます。これまでと違うのは、組番号・名前を取得するようにしたことです。この機能がないと採点結果を表示できないので、大事な機能になります。また、前の工程で模範解答の文字数が50文字以上のものは長文問題として別の変数に格納しましたが、ここでも生徒の回答を読み取るところで長文・普通の問題で分けて、変数に格納しています。
次にこれまでと同様に、読み取った回答を修正するできるようにします。
アプリ機能⑥ =読み取った生徒の回答を修正=
student_data = st.session_state.student_data
num_question = st.selectbox("修正したい問題番号を選んでください", list(range(len(st.session_state['question_data']))))
# 画像の高さを揃えるための設定
target_height = 150 # 目標の高さ(ピクセル)
# 選ばれた問題番号に基づく生徒の回答を修正可能な状態で表示する
cols = st.columns(5) # 5列のカラムを作成
col_index = 0 # 現在のカラムインデックス
for student in student_data:
st.subheader(f"{student['class_number']} {student['name']}")
for ans in student['answers']:
if ans['question_number'] == num_question:
question_text = ans['question']
student_answer = ans['student_answer']
score = ans['score']
xywh = ans['xywh']
student_roi = ans['student_roi'] # 生徒の回答領域画像
# 生徒の回答領域画像を表示
student_image = Image.fromarray(np.array(student_roi))
# 画像のリサイズ(高さを揃える)
width, height = student_image.size
aspect_ratio = width / height
new_width = int(target_height * aspect_ratio)
resized_image = student_image.resize((new_width, target_height))
with cols[col_index]:
st.image(resized_image, caption=f"{student['name']} の回答", use_column_width=True)
corrected_answer = st.text_area(f"回答: {student_answer}", key=f"{student['file']}_{xywh}", value=student_answer)
ans['student_answer'] = corrected_answer
# 次のカラムに移動
col_index += 1
if col_index == 5: # 5列に達したら改行
col_index = 0
cols = st.columns(5) # 新しい行を作成
for ans in student['long_context']:
if ans['question_number'] == num_question:
question_text = ans['question']
student_answer = ans['student_answer']
score = ans['score']
xywh = ans['xywh']
student_roi = ans['student_roi'] # 生徒の回答領域画像
# 生徒の回答領域画像を表示
student_image = Image.fromarray(np.array(student_roi))
# 画像のリサイズ(高さを揃える)
width, height = student_image.size
aspect_ratio = width / height
new_width = int(target_height * aspect_ratio)
resized_image = student_image.resize((new_width, target_height))
with cols[col_index]:
st.image(resized_image, caption=f"{student['name']} の回答", use_column_width=True)
corrected_answer = st.text_area(f"回答: {student_answer}", key=f"{student['file']}_{xywh}", value=student_answer)
ans['student_answer'] = corrected_answer
# 次のカラムに移動
col_index += 1
if col_index == 5: # 5列に達したら改行
col_index = 0
cols = st.columns(5) # 新しい行を作成
# 修正内容を保存するボタン
if st.button("修正内容を保存"):
st.session_state.student_data = student_data
st.success("修正内容が保存されました!")
ここでは読み取った生徒の回答を問題番号ごとに、表示するようにしています。理由としては生徒ごとに見て修正していくのは、非効率的だと思ったからです。機能についてはこれまでの工程で出てきた機能とほとんど一緒になります。修正が完了しましたら、最後に採点をして結果を表示していきます。
終わりに
今回は以上で終わりにしたいと思います。
次回は採点・結果の表示の部分を説明していこうと思います。
もし何か間違っているところや、不備がありましたらコメントお願いいたします。