月別アーカイブ: 2025年4月

デジタル採点 手書き フリー で検索したら、その後

前回の記事を書いてから、scikit-learn を使った機械学習による手書きカタカナ文字「ア・イ・ウ・エ・オ」及び記号「○・×」の認識用学習モデル作成について、さらに勉強しました☆

今回は、その記録と、今後の抱負です。

【もくじ】

1.さらに勉強した理由
2.HOGを知る
3.気分は「写経」
4.今後の抱負
5.まとめ
6.お願いとお断り

1.さらに勉強した理由

なぜ、さらに勉強したかというと、前回の記事では、画像のピクセル値をそのまま利用する Flattening という特徴量抽出の手法を用いて学習モデルを作成したのですが、前回の記事にある通り、既知の(=学習に利用した)カタカナ文字については、アイウエオ各文字ともに 98 %正しく判定できたという好結果に力を得て、Delphi で GUI を作成した手書き答案の採点補助プログラムから、Python の文字認識スクリプトを実行できるよう、新しくプログラムを書いて実験してみた結果、期待に反して1回も見たことのない新規の文字については、正しく判定できないことがありました。特に「オ」は全滅・・・

以下、かるーくやってみた実験の結果です。

多少の傾きはOK?

「ア」はふたつとも読めた・・・

文字の記入位置は影響なし?

記入位置の探索も、上手く行えてるようです・・・

なぜ、読めない? この「ウ」は読んで欲しかった・・・

ふたつめの「ウ」の方が、典型的な「ウ」により近い? 気がするけど・・・

「エ」は判定しやすい?

「エ」は得意なのかな・・・

この「オ」が見分けられないとは・・・ T_T


この「オ」の認識結果を見て、正直、これはダメだと思いました。また、失敗です。T_T

ちなみに「○・×」は・・・

なにか書いてあれば・・・「○」だと思ってる・・・
(空欄を識別しているのは、うれしい限りですが)


「○ or ×」認識テストの結果は、「オ」の場合よりさらにダメです。まぁ上の「オ」の場合の「ア」についても確信を持って見分けて「×」を付けているのか、どうか、この結果を見てだいぶ怪しくなってきました。(果たして、あの「オ」や「ア」をどう読んだのか・・・、それを確認する気力も失せました・・・)

さらに、お見せしたくないのが、「×」が正解ラベルの場合です。

もうダメです。T_T

THE END.
その想いで胸がいっぱいに!

実装が超シンプルで、かつ高速で軽量、文字画像のピクセル値(28×28)をそのまま利用する Flattening という手法では、これが限界なのでしょうか?

学習用データをさらに増やせば、もっと良い結果が得られるのではないか・・・ とも考えましたが、手元にその学習用データがありません。新規に学習用データを集めるには莫大な手間と時間が必要です。

ただ・・・失敗の中でも唯一救いに感じたのは、2年前の文字認識チャレンジでどうしてもクリア出来なかった解答欄中の文字が書かれている位置を正しく認識することに成功し、意図した通りに文字画像を取得出来ていることです。

No,1の「イ」は解答欄の左側に記入されていますが・・・
No,1の「イ」も正しく切り出せています


プログラムはその記入位置を正確に見つけ出し、28×28の矩形画像への切り出しに成功しています。

実は、この Blog の過去の記事で「失敗の記録」として掲載した手書き文字認識チャレンジの試行錯誤の記事を書いた当時、文字の認識に失敗した最大の原因は「正しく文字を切り出せなかった」ことにありました。今回、テストしたのは、たった3枚の画像ですが、いずれも問題なく文字が記入されている位置をプログラムは特定し、その正確な切り出しに成功しています。

切り出した画像の縦横比が、元の画像と変化していることに、画像を見て気づきました!
ここは出来れば改善したいところです。


2年前の僕の技術では、例えば「ア」について、文字を構成する線がすべて繋がっている場合は「ア」という文字1文字だと正しく認識できても、「つ」部分と「ノ」部分が離れている場合は、「ア」ではなく「つ」と「ノ」に分解して認識してしまうミスをどうしても防げなかったのです。今回のチャレンジでは、この問題を無事クリアできました。

2年前の僕の技術では、3つめの「ア」は「つ」と「ノ」になってしまいましたが・・・
今回のプログラムは、ちゃんと「ア」として切り出しています。
ただ、やはり縦横比が・・・気になりますので、ここは何とかします!


さらに、解答欄から切り出した文字の位置が切り出し画像の中央にあることも、長い間ずっと・・・ この胸に思い描いた夢の通りです。

文字の縦横比は変わっていますが、文字位置の特定には何の問題もなく、成功しています☆
さらに、解答欄左にある(5)のような解答欄の番号を無視することにも成功しています☆☆
2年前にどうしてもクリア出来なかった複数の問題を、今回はすべてクリア出来ました☆☆☆

総合的な意味では今回も失敗でしたが、自分にとって、前回、クリア出来なかった幾つもの問題を解決できたことは、本当に大きな前進でした。だから、総合的には失敗でも、☆5つが完全な成功だとしたら、自分的には ☆☆☆ です。

また、今回、Flattening による学習モデル作成方法を学ぶことで、Python に 32 ビット環境の scikit-learn ライブラリを導入する手法を完全に理解できました。機械学習そのものが現在 64 ビット環境へ移行しつつある中で、32 ビット環境の最後の輝きを、今、僕は目の当たりにしている・・・ そんな気がしてなりませんでした。

2.HOGを知る

Flattening の欠点に気づいたのは、Python 環境で作成した学習モデルを Delphi の Object Pascal から操作できるようにプログラミングを終えた段階(上の画像は、その段階での試行の様子)だったので、・・・結果的に Delphi 側の最も重要なプログラムを最初から組み直すことにはなりましたが・・・ ここで僕は、エッジや輪郭の方向に強く、ノイズの影響も受けにくい HOG(Histogram of Oriented Gradients)という特徴量を抽出する手法があることを知ります。HOG を勉強してみたところ、こちらの手法の方が画像のピクセル値のそのまま利用する Flattening より、文字の識別精度が高いのではないかと思えてきました。

そこで HOG を用いて文字の特徴量を抽出して学習モデルを作成するスクリプトを書きました。最初に、ごく基本的なコードを書き、そこに必要な様々な処理を追加して行く方法で一歩一歩確実に進んだ結果、文字の認識能力が Flattening 特徴量抽出手法を使ったそれよりは高いのではないか?と、確かに思える学習モデルを作成することができました。HOG 特徴量抽出手法を使った学習モデルは、Delphi に組み込む前に、Python スクリプトを使って行った試行で、上の「オ」を2つともサラっと認識してくれたのです!

試行の様子がこちらです。

解答用紙から切り出した解答欄の矩形画像


さらに解答欄の中の文字部分を探索して、切り抜いて・・・

解答欄から切り出した28×28ピクセルの矩形画像
(新しいプログラムではファイル名のIndexは1始まりにしました)


Delphi に埋め込む前に、Python 用のスクリプトで読んでみます・・・

やった! ちゃんと読めた!! 「オ」だけじゃなく「ア」も正しく読めています!!!


以下、HOG特徴量抽出手法を適用した学習モデル作成に必要な、学習用の文字データを作成するために使用したスクリプトです(使用を推奨するものではありません。あくまでもご参考まで)。

このスクリプトは、輪郭検出と文字切り出し、周囲パディングを均一化して、文字を画像の中心に配置、GaussianBlurによるノイズ除去、傾き補正、28×28ピクセルに正規化して保存・・・と言った機能を備えています。万一、コピペして試される場合は PATH をご自身の環境に合わせて変更してください。

import cv2
import numpy as np
import os
from glob import glob
import re

# UTF-8 パス対応の画像読み込み
def imread_utf8(path):
    stream = np.fromfile(path, dtype=np.uint8)
    return cv2.imdecode(stream, cv2.IMREAD_COLOR)

# 傾き補正(修正: warpAffine に補間法と白背景を明示)
def deskew(img):
    m = cv2.moments(img)
    if abs(m['mu02']) < 1e-2:
        return img.copy()
    skew = m['mu11'] / m['mu02']
    M = np.float32([[1, skew, -0.5 * 28 * skew], [0, 1, 0]])
    return cv2.warpAffine(img, M, (28, 28), flags=cv2.INTER_NEAREST | cv2.WARP_INVERSE_MAP, borderValue=255)

# ファイル名から数値を抽出(img12.png → 12)
def extract_number(path):
    filename = os.path.basename(path)
    match = re.search(r'img(\d+)', filename)
    return int(match.group(1)) if match else float("inf")

# 入力・出力フォルダ(パスに全角文字が含まれていてもOK)
input_folder = r"C:\Python39-32\Images_tegaki\aiueo\ア"
output_folder = os.path.join(input_folder, "Trimed")
os.makedirs(output_folder, exist_ok=True)

# 対象画像拡張子
image_extensions = ['*.jpg', '*.jpeg', '*.png']
image_files = []
for ext in image_extensions:
    image_files.extend(glob(os.path.join(input_folder, ext)))

# 並べ替え(img番号順)
image_files.sort(key=extract_number)

index = 1
for image_path in image_files:
    image = imread_utf8(image_path)
    if image is None:
        print(f"読み込めない画像: {image_path}")
        continue

    h, w = image.shape[:2]
    gray_for_line = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray_for_line, 50, 150, apertureSize=3)

    raw_lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100,
                                minLineLength=min(w, h) // 3, maxLineGap=10)
    filtered_lines = []
    if raw_lines is not None:
        for line in raw_lines:
            x1, y1, x2, y2 = line[0]
            angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
            length = np.hypot(x2 - x1, y2 - y1)
            if (angle < 10 or angle > 170) and length < w // 2:
                continue
            filtered_lines.append([[x1, y1, x2, y2]])

    if filtered_lines:
        for line in filtered_lines:
            x1, y1, x2, y2 = line[0]
            if abs(x2 - x1) < 10 or abs(y2 - y1) < 10:
                cv2.line(image, (x1, y1), (x2, y2), (255, 255, 255), thickness=3)

    if w > h:
        offset = w // 4
        cropped = image[:, offset:w - offset]
    else:
        offset = h // 4
        cropped = image[offset:h - offset, :]

    gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)
    _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)

    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))
    dilated = cv2.dilate(thresh, kernel, iterations=1)
    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    if contours:
        all_points = np.vstack(contours)
        x, y, w_box, h_box = cv2.boundingRect(all_points)
        padding = 20  # この値は、切り抜き画像を確認しつつ、適宜調整してください。
        if w > h:
            x += offset
        else:
            y += offset

        x1 = max(0, x - padding)
        y1 = max(0, y - padding)
        x2 = min(w, x + w_box + padding)
        y2 = min(h, y + h_box + padding)

        trimmed = image[y1:y2, x1:x2]
        trimmed_gray = cv2.cvtColor(trimmed, cv2.COLOR_BGR2GRAY)
        trimmed_blur = cv2.GaussianBlur(trimmed_gray, (3, 3), 0)

        h_trim, w_trim = trimmed_blur.shape[:2]
        scale = 20.0 / max(h_trim, w_trim)
        new_w = int(w_trim * scale)
        new_h = int(h_trim * scale)
        # resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_AREA)
        resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_NEAREST)

        canvas = np.full((28, 28), 255, dtype=np.uint8)
        x_offset = (28 - new_w) // 2
        y_offset = (28 - new_h) // 2
        canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized        

        deskewed = deskew(canvas)

        # モーメントで中心を合わせる(修正: warpAffine に補間法と白背景を明示)
        M = cv2.moments(deskewed)
        if M['m00'] != 0:
            cx = int(M['m10'] / M['m00'])
            cy = int(M['m01'] / M['m00'])
            shift_x = 14 - cx
            shift_y = 14 - cy
            trans_mat = np.float32([[1, 0, shift_x], [0, 1, shift_y]])
            deskewed = cv2.warpAffine(deskewed, trans_mat, (28, 28), flags=cv2.INTER_NEAREST, borderValue=255)

        canvas = deskewed
    else:
        print(f"文字が検出されませんでした: {os.path.basename(image_path)}")
        canvas = np.full((28, 28), 255, dtype=np.uint8)

    # 保存(全角パスにも対応)
    save_path = os.path.join(output_folder, f"{index:04d}.png")
    is_success, encoded_img = cv2.imencode('.png', canvas)
    if is_success:
        encoded_img.tofile(save_path)
        print(f"{save_path} を保存しました。")
    else:
        print(f"{save_path} の保存に失敗しました。")

    index += 1

print("すべての画像の処理が完了しました。")


上のスクリプトで 28×28 ピクセルに整形して保存した大量の学習用データ画像を、次のスクリプトで処理して学習モデルを生成します。こちらについても、万一、コピペして試される場合は PATH をご自身の環境に合わせて変更してください(こちらも使用を推奨するものではありません。あくまでもご参考まで)。

import cv2
import numpy as np
from sklearn import svm
from sklearn.model_selection import train_test_split
import os
import joblib  # モデルの保存と読み込みに使用
from skimage.feature import hog
from sklearn.svm import SVC

# カタカナのクラス
CATEGORIES = ["ア", "イ", "ウ", "エ", "オ"]

# Pathの中の日本語に対応
def imread(filename, flags=cv2.IMREAD_GRAYSCALE, dtype=np.uint8):
    try:
        n = np.fromfile(filename, dtype)
        img = cv2.imdecode(n, flags)
        return img
    except Exception as e:
        print(e)
        return None

# HOG特徴量を抽出する関数
def extract_hog_features(img):
    # 画像はすでに28x28の想定
    features = hog(img,
                   orientations=9,
                   pixels_per_cell=(4, 4),
                   cells_per_block=(2, 2),
                   block_norm='L2-Hys')
    return features

# データセットの準備(28x28 の手書きカタカナ画像)
def load_images_from_folder(folder, categories):
    images = []
    labels = []
    for label, category in enumerate(categories):
        path = os.path.join(folder, category)
        print(f"Processing category: {category}, Path: {path}")

        if not os.path.exists(path):
            print(f"Warning: Path does not exist: {path}")
            continue

        for file in os.listdir(path):
            if file.lower().endswith(('.png', '.jpg', '.jpeg')):
                file_path = os.path.join(path, file)
                try:
                    img = imread(file_path)
                    if img is not None:
                        img = cv2.resize(img, (28, 28))
                        hog_features = extract_hog_features(img)
                        images.append(hog_features)
                        labels.append(label)
                    else:
                        print(f"Failed to load image: {file_path}")
                except Exception as e:
                    print(f"Error loading {file_path}: {e}")
            else:
                print(f"Skipping non-image file: {file}")
    print(f"Loaded {len(images)} images")
    return np.array(images), np.array(labels)

# データ読み込み
X, y = load_images_from_folder(r"C:\Python39-32\Images_tegaki\aiueo\Trimed", CATEGORIES)

if len(X) == 0:
    raise ValueError("No images loaded. Please check the image files and paths.")

# 学習とテストの分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# SVM モデルの作成と学習
model = svm.SVC(kernel='linear')
model.fit(X_train, y_train)

# モデルを保存する
joblib.dump(model, 'katakana_hog_svm_model.pkl')
print("Model saved as 'katakana_hog_svm_model.pkl'")

# 予測用前処理(HOG版)
def preprocess_image(image_path):
    img = imread(image_path)
    h, w = img.shape

    size = max(h, w)
    square_img = np.full((size, size), 255, dtype=np.uint8)
    x_offset = (size - w) // 2
    y_offset = (size - h) // 2
    square_img[y_offset:y_offset + h, x_offset:x_offset + w] = img

    img_resized = cv2.resize(square_img, (28, 28))
    hog_features = extract_hog_features(img_resized)
    return hog_features

def predict_character(image_path):
    img = preprocess_image(image_path)
    model = joblib.load('katakana_hog_svm_model.pkl')
    label = model.predict([img])[0]
    return CATEGORIES[label]

# テスト画像の認識(テスト用の画像は実行中のスクリプトと同じフォルダに用意・保存する)
for image_path in [
    "katakana_sample_a.jpg",
    "katakana_sample_i.jpg",
    "katakana_sample_u.jpg",
    "katakana_sample_e.jpg",
    "katakana_sample_o.jpg"
]:
    result = predict_character(image_path)
    print(f"{os.path.basename(image_path)} の認識結果: {result}")

テストに使用した画像は、次の通りです。文字の太さはテスト用に変化のあるものを選びました。

katakana_sample_a.jpg
katakana_sample_i.jpg
katakana_sample_u.jpg
katakana_sample_e.jpg
katakana_sample_o.jpg

上記、学習モデルを作成するスクリプトの実行結果です。

幸先よし。満足できる結果を得ることができました!

3.気分は「写経」

次は、完成した学習モデルをDelphiから使えるようにすれば OK なのですが、この作業は毎回「写経」を行っているような気持ちを感じる作業です。・・・と、言う僕自身、写経の経験は皆無ですが・・・ この業界で一般的に使用される「写経」的意味合いと、ここでのそれは異なり、感覚的にはむしろ「修行」に近いものです。

次のコードを見ていただければ、なぜ「修行」なのか、ご理解いただけると思います。

procedure TFormCollaboration.btnAutoClick(Sender: TObject);
var
  strScrList:TStringList;
  strAnsList:TStringList;
  j:integer;
  intCols:integer;
  results: TArray<string>;
  s: string;
begin
  // ・・・ 略 ・・・
  try

    //Scriptを入れるStringList
    strScrList:=TStringList.Create;

    //手書き文字の認識結果
    strAnsList:=TStringList.Create;

    try

      strScrList.Add('import cv2');
      strScrList.Add('import numpy as np');
      strScrList.Add('import os');
      strScrList.Add('from glob import glob');
      strScrList.Add('import re');
      strScrList.Add('from skimage.feature import hog');
      strScrList.Add('import joblib');

      //カタカナラベル
      if (cmbAL.Text = 'ア') or (cmbAL.Text = 'イ') or (cmbAL.Text = 'ウ') or (cmbAL.Text = 'エ') or (cmbAL.Text = 'オ') then
      begin
        strScrList.Add('CATEGORIES = ["ア", "イ", "ウ", "エ", "オ"]');
      end;

      //○×ラベル
      if (cmbAL.Text = '○') or (cmbAL.Text = '×') then
      begin
        strScrList.Add('CATEGORIES = ["○", "×"]');
      end;

      //HOG特徴量抽出
      strScrList.Add('def extract_hog_features(img):');
      strScrList.Add('    features = hog(img, orientations=9, pixels_per_cell=(4, 4), cells_per_block=(2, 2), block_norm="L2-Hys")');
      strScrList.Add('    return features');

      //UTF-8 パス対応の画像読み込み
      strScrList.Add('def imread_utf8(path):');
      strScrList.Add('    stream = np.fromfile(path, dtype=np.uint8)');
      strScrList.Add('    return cv2.imdecode(stream, cv2.IMREAD_COLOR)');

      //傾き補正
      strScrList.Add('def deskew(img):');
      strScrList.Add('    m = cv2.moments(img)');
      strScrList.Add('    if abs(m["mu02"]) < 1e-2:');
      strScrList.Add('        return img.copy()');
      strScrList.Add('    skew = m["mu11"] / m["mu02"]');
      strScrList.Add('    M = np.float32([[1, skew, -0.5 * 28 * skew], [0, 1, 0]])');
      strScrList.Add('    return cv2.warpAffine(img, M, (28, 28), flags=cv2.WARP_INVERSE_MAP, borderValue=255)');

      //ファイル名から数値を抽出(crop_Img12.png → 12)
      strScrList.Add('def extract_number(path):');
      strScrList.Add('    filename = os.path.basename(path)');
      strScrList.Add('    match = re.search(r"crop_Img(\d+)", filename)');
      strScrList.Add('    return int(match.group(1)) if match else float("inf")');

      //文字認識処理
      strScrList.Add('def predict_character(img, model):');
      strScrList.Add('    hog_features = extract_hog_features(img)');
      strScrList.Add('    label = model.predict([hog_features])[0]');
      strScrList.Add('    return CATEGORIES[label]');

      //モデル読み込み
      //カタカナラベル
      if (cmbAL.Text = 'ア') or (cmbAL.Text = 'イ') or (cmbAL.Text = 'ウ') or (cmbAL.Text = 'エ') or (cmbAL.Text = 'オ') then
      begin
        strScrList.Add('model_path = r".\Python39-32\katakana_hog_svm_model.pkl"');
      end;

      //○×ラベル
      if (cmbAL.Text = '○') or (cmbAL.Text = '×') then
      begin
        strScrList.Add('model_path = r".\Python39-32\mb_hog_svm_model.pkl"');
      end;

      strScrList.Add('if not os.path.exists(model_path):');
      strScrList.Add('    raise FileNotFoundError(f"モデルファイルが見つかりません: {model_path}")');
      strScrList.Add('model = joblib.load(model_path)');

      //入力・出力フォルダ
      //strScrList.Add('base_path = r".\imgAuto\src"');
      strScrList.Add('input_folder = r".\imgAuto\src"');
      //strScrList.Add('folder_path = os.path.join(base_path, CORRECT_LABEL)');
      strScrList.Add('output_folder = os.path.join(input_folder, "'+ cmbAL.Text +'")');
      strScrList.Add('os.makedirs(output_folder, exist_ok=True)');

      //対象画像を取得
      strScrList.Add('image_extensions = ["*.jpg", "*.jpeg", "*.png"]');
      strScrList.Add('image_files = []');
      strScrList.Add('for ext in image_extensions:');
      strScrList.Add('    image_files.extend(glob(os.path.join(input_folder, ext)))');
      strScrList.Add('image_files.sort(key=extract_number)');

      strScrList.Add('results = []');

      strScrList.Add('index = 1');
      strScrList.Add('for image_path in image_files:');
      strScrList.Add('    image = imread_utf8(image_path)');
      strScrList.Add('    if image is None:');
      strScrList.Add('        print(f"読み込めない画像: {image_path}")');
      strScrList.Add('        continue');

      strScrList.Add('    h, w = image.shape[:2]');
      strScrList.Add('    gray_for_line = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('    edges = cv2.Canny(gray_for_line, 50, 150, apertureSize=3)');

      strScrList.Add('    raw_lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=100, minLineLength=min(w, h) // 3, maxLineGap=10)');
      strScrList.Add('    filtered_lines = []');
      strScrList.Add('    if raw_lines is not None:');
      strScrList.Add('        for line in raw_lines:');
      strScrList.Add('            x1, y1, x2, y2 = line[0]');
      strScrList.Add('            angle = abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)');
      strScrList.Add('            length = np.hypot(x2 - x1, y2 - y1)');
      strScrList.Add('            if (angle < 10 or angle > 170) and length < w // 2:');
      strScrList.Add('                continue');
      strScrList.Add('            filtered_lines.append([[x1, y1, x2, y2]])');

      strScrList.Add('    if filtered_lines:');
      strScrList.Add('        for line in filtered_lines:');
      strScrList.Add('            x1, y1, x2, y2 = line[0]');
      strScrList.Add('            if abs(x2 - x1) < 10 or abs(y2 - y1) < 10:');
      strScrList.Add('                cv2.line(image, (x1, y1), (x2, y2), (255, 255, 255), thickness=3)');

      strScrList.Add('    if w > h:');
      strScrList.Add('        offset = w // 4');
      strScrList.Add('        cropped = image[:, offset:w - offset]');
      strScrList.Add('    else:');
      strScrList.Add('        offset = h // 4');
      strScrList.Add('        cropped = image[offset:h - offset, :]');

      strScrList.Add('    gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('    _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)');

      strScrList.Add('    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (10, 10))');
      strScrList.Add('    dilated = cv2.dilate(thresh, kernel, iterations=1)');
      strScrList.Add('    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)');

      strScrList.Add('    if contours:');
      strScrList.Add('        all_points = np.vstack(contours)');
      strScrList.Add('        x, y, w_box, h_box = cv2.boundingRect(all_points)');

      //strScrList.Add('        padding = 20');
      strScrList.Add('        padding = 5');
      strScrList.Add('        if w > h:');
      strScrList.Add('            x += offset');
      strScrList.Add('        else:');
      strScrList.Add('            y += offset');

      strScrList.Add('        x1 = max(0, x - padding)');
      strScrList.Add('        y1 = max(0, y - padding)');
      strScrList.Add('        x2 = min(w, x + w_box + padding)');
      strScrList.Add('        y2 = min(h, y + h_box + padding)');

      strScrList.Add('        trimmed = image[y1:y2, x1:x2]');
      strScrList.Add('        trimmed_gray = cv2.cvtColor(trimmed, cv2.COLOR_BGR2GRAY)');
      strScrList.Add('        trimmed_blur = cv2.GaussianBlur(trimmed_gray, (3, 3), 0)');

      strScrList.Add('        h_trim, w_trim = trimmed_blur.shape[:2]');
      strScrList.Add('        scale = 20.0 / max(h_trim, w_trim)');
      strScrList.Add('        new_w = int(w_trim * scale)');
      strScrList.Add('        new_h = int(h_trim * scale)');
      strScrList.Add('        resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_AREA)');

      strScrList.Add('        canvas = np.full((28, 28), 255, dtype=np.uint8)');
      strScrList.Add('        x_offset = (28 - new_w) // 2');
      strScrList.Add('        y_offset = (28 - new_h) // 2');
      strScrList.Add('        canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized');

      strScrList.Add('        deskewed = deskew(canvas)');

      strScrList.Add('        M = cv2.moments(deskewed)');
      strScrList.Add('        if M["m00"] != 0:');
      strScrList.Add('            cx = int(M["m10"] / M["m00"])');
      strScrList.Add('            cy = int(M["m01"] / M["m00"])');
      strScrList.Add('            shift_x = 14 - cx');
      strScrList.Add('            shift_y = 14 - cy');
      strScrList.Add('            trans_mat = np.float32([[1, 0, shift_x], [0, 1, shift_y]])');
      strScrList.Add('            deskewed = cv2.warpAffine(deskewed, trans_mat, (28, 28), borderValue=255)');

      strScrList.Add('        canvas = deskewed');
      strScrList.Add('        predicted_char = predict_character(canvas, model)');
      strScrList.Add('        results.append(str(predicted_char))');
      strScrList.Add('    else:');
      strScrList.Add('        results.append("")');
      strScrList.Add('        canvas = np.full((28, 28), 255, dtype=np.uint8)');

      strScrList.Add('    save_path = os.path.join(output_folder, f"{index:04d}.png")');
      strScrList.Add('    is_success, encoded_img = cv2.imencode(".png", canvas)');
      strScrList.Add('    if is_success:');
      strScrList.Add('        encoded_img.tofile(save_path)');
      strScrList.Add('    index += 1');

      strScrList.Add('var1.Value = ";".join(results)');

      try
        PythonEngine1.ExecStrings(strScrList);
      except
        on E: Exception do
        begin
          ShowMessage('Pythonスクリプトの実行中にエラーが発生しました: ' + E.Message);
          Exit;
        end;
      end;

      strAnsList.Clear;

      if Assigned(PythonDelphiVar1) then
      begin
        s := PythonDelphiVar1.ValueAsString;
        if s <> '' then
        begin
          results := SplitString(s, ';');
          for s in results do
            strAnsList.Add(s);
        end else begin
          ShowMessage('sは空欄!');
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
      end;

      if Assigned(PythonDelphiVar1) then
      begin
        for j := 0 to strAnsList.Count - 1 do
        begin
          if cmbAL.Text = strAnsList[j] then
            StringGrid1.Cells[intCols,j+1] := cmbRendo.Text
          else
            StringGrid1.Cells[intCols,j+1] := '0';
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
        Exit;
      end;

    finally
      //StringListの解放
      strScrList.Free;
      strAnsList.Free;
    end;

    // ・・・ 略 ・・・
end;

エンエンと続く strScrList.Add( ) そう! ここに Python のスクリプトの1行1行をコピペして行くのです。20 行目くらいから、だんだん、まぶたが重くなり・・・、50 行目まで到達する頃には、意識が朦朧としてきて・・・、残り数行という段階で、ほぼ涅槃の境地に・・・

「涅槃」とは、「一切の煩悩から解脱した、不生不滅の高い境地」であり、「煩悩の火が消え、人間が持っている本能から解放され、心の安らぎを得た状態のこと」をいうのだそうです。

・・・

失礼しました。間違えました。僕のは単に眠くなり、もう何も考えられない状態になっただけです。

何はともあれ、いずれにしてもそのいちばん心が「無」になった状態で、最大の難関が待ち受けています。それは何かというと、Python 側から Delphi 側への判定結果の受け渡しの手続きの記述です。

元々の Python 側でのスクリプトは・・・

        predicted_char = predict_character(canvas, model)
        print(f"{os.path.basename(image_path)} → 認識結果: {predicted_char}")
    else:
        print(f"{os.path.basename(image_path)} → 文字が検出されませんでした。")

ここを、次のように書き換えます。※ results リストは予め空になるよう初期化しておきます。

      strScrList.Add('        predicted_char = predict_character(canvas, model)');
      strScrList.Add('        results.append(str(predicted_char))');
      strScrList.Add('    else:');
      strScrList.Add('        results.append("")');

最後に Delphi 側へ、プレゼント☆

      strScrList.Add('var1.Value = ";".join(results)');

で、Delphi 側では、results に保存されている認識結果を StringList で受け取って、StringGrid に得点を表示します。

      strAnsList.Clear;

      if Assigned(PythonDelphiVar1) then
      begin
        s := PythonDelphiVar1.ValueAsString;
        if s <> '' then
        begin
          results := SplitString(s, ';');
          for s in results do
            strAnsList.Add(s);
        end else begin
          ShowMessage('sは空欄!');
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
      end;

      if Assigned(PythonDelphiVar1) then
      begin
        for j := 0 to strAnsList.Count - 1 do
        begin
          if cmbAL.Text = strAnsList[j] then
            StringGrid1.Cells[intCols,j+1] := cmbRendo.Text  //得点を指定
          else
            StringGrid1.Cells[intCols,j+1] := '0';
        end;
      end else begin
        ShowMessage('PythonDelphiVar1 が未定義です');
        Exit;
      end;

もちろん、PATH も、exe のある階層が起点となるように修正して・・・

      //入力・出力フォルダ (cmbAL = ComboBox Answer Label)
      strScrList.Add('input_folder = r".\imgAuto\src"');
      strScrList.Add('output_folder = os.path.join(input_folder, "'+ cmbAL.Text +'")');
      strScrList.Add('os.makedirs(output_folder, exist_ok=True)');

こうして、なんとか、エラーを出さずに、プログラムが「動く」状態にまで仕上げました。

追記_20250421

ふと思ったのですが、Form に非表示の TMemo を1つおいて、そこに Python のスクリプトをコピペして、必要な部分のみ上記のように変更すれば、

もっとラクできたかな・・・

みたいな気が。

でも、「修行」には「修行」で、また、

別の意味と価値がある

ような・・・ 気も。

ただ、このプログラムの・・・ 究極の目的は、採点者が単に「ラクする」ためだけの・・・ 採点環境を実現することにある・・・ という事実。

いや、それは「ヒトと機械との美しき協働」の穿った見方。

こんな相反する「矛盾」を、感じるのは作者である僕だけ?

まぁ、全部をまとめて言えば・・・

人生は必ず ± 0になる
ということでしょうか?

なお、Python4Delphi の設定と使い方の詳細は、次の過去記事をご参照ください。

早速、冒頭に紹介したのと同じデータを読んで、動作確認。

Delphiへのスクリプト移植前に試行していたので、
あまりドキドキせずに「自動」ボタンをクリックすることができました!

ボタンの Caption は「自動」より、「実行」の方がよかったかな・・・?

記入位置も、多少の傾きも、問題なくクリアできました。


気になっていた切り出し画像の縦横比も・・・

縦横比が変化しないようにスクリプトを修正できました!

前回は、正しく読めなかった「ウ」も、この通り読めています。

やった! やった!!

長かった・・・ けれど、ここまで来ることができました☆
あきらめなくて、よかった・・・

「エ」も余裕?でクリア

イイ感じというか、エエ感じというか・・・

そして、Flattening 特徴量抽出で作成した学習モデルでは読めなかった「オ」・・・ ですが、

やったー!!!

HOG特徴量抽出で作成した学習モデルは、しっかり読んでくれました!

もちろん、「○・×」判定も・・・ 余裕でOK!
(何が余裕なのかは、僕自身、わかってないですが)

最初に正解ラベル「○」の場合、

自分的には、HOGで作った学習モデルへの「信頼感」みたいなモノが生まれてきました☆


次に、正解ラベル「×」の場合、

100% 正解しました!


これなら販売できそうです。
まぁ買ってくれる人は、
いないと思いますが・・・ *(^_^)*♪

4.今後の抱負

テストとは、とても言えないような、ほんとうに取り急ぎの採点試行結果ですので、これだけを持って公開してOK!とは、とても思えません。実際の採点現場で性能を確認できたら、自作のデジタル採点ソフト AC_Reader のバージョンアップ版として、この blog の未来記事で公開したいと思います。

5.まとめ

手書き文字認識に scikit-learn を使って成功するためには・・・

(1)特徴量抽出前の学習データ作成を丁寧に行い、機械学習しやすい環境を整える。
(2)学習データが同じである場合、Flattening より HOG 特徴量抽出の方が良い結果を出せた。
(3)誤りがあれば必ず修正し、成功するまで、絶対にあきらめないこと。

6.お願いとお断り

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

デジタル採点 手書き フリー で検索したら

久しぶりに、上のキーワードで Google 検索して、びっくり しました。
なんと! 検索結果の・・・ いちばん上に! ・・・ 僕のプログラムが、表示されてる・・・

(⊙_⊙)

正直。うれしいより先に

やばい!

・・・と、思いました。

( 何ページ目くらいに表示されるのかなー☆ )

本当に、それが、これまでに何度も、何回も繰り返した、僕の blog を Google 検索する時の想い。

( 誰か、見てくれないかなー。読んでもらえたら、うれしいなー☆ )

だから、3ページ目くらいに記事があると、「うん。うん。」って、安心してた・・・。

blog を書くこと自体が、自分の存在確認の行為に他ならないのだけれど・・・

これは本当に思い上がりとか、謙遜とか、そのどちらでもなく・・・

普通に考えて・・・

僕の blog とプログラムが
Google の検索結果で
トップに表示されるわけがない。

どう考えても、それが僕のいる世界の「本当」・・・のはず、なのに ・・・
突然! 目の前に表示された「画面」という現実を、それでもなお、信じられない気持ちで、眺めつつ。

夢なら覚めないでほしい

そう思ったのも、また、事実です。

この2年間の日々は、色々な意味で、ほんとうに、本当に、苦しかった・・・。

人の立場の違いは、その評価をも、真逆に変える。

あの日、拍手で歓迎されたプログラムが、ただのゴミ以下になる・・・

僕は、そのほんとうを・・・ 確かに、この目で、見ました。

失意のどん底にある僕を支えてくださった多くの方々に、心から感謝申し上げます。

だから、Google 先生の、僕の blog とプログラムへの評価は、世の中が僕の夢を応援してくれている証明のように思えて、「やばい」と思ったのは本当ですが、やはり、とても、うれしかったのです。

で、問題は「やばい」と感じた理由・・・ そう、今回の記事を書く きっかけ です。

2年前、同僚の要請に応えるかたちで、手書き答案をスキャンして得た画像から個々の解答欄画像を切り出して一括採点し、採点記号その他を付加して元の画像に書き戻すデジタル採点プログラムの最初のバージョンを書き、「表形式」の解答欄を読み取って処理するので「Answer Column Reader = AC_Reader」と名付けたのですが・・・

その時点で、プロの書いたデジタル採点システムにあって、僕のプログラムにないもの・・・

そう「○・×」、「ア・イ・ウ・エ・オ」、「A・B・C」、「1・2・3」みたいな記号・文字または数字1字の解答であれば自動採点できる機能を僕のプログラムにも搭載したいと、僕はごく自然な流れで考えたのです。

当時、年末・年始の休暇を含めて、ほぼ2か月間、手書き文字の認識に没頭した記憶があります。

その記録は当 blog の過去記事にある通りです。

いずれも、他人様の実験結果を、ただ真似しただけの、読むに値しない記事ですが・・・

生成 AI なんてまだなかったあの頃・・・(知らないところで、それは・・・ ほぼ出来上がりつつあったのだろうけれど・・・。 そう、考えると同時期にレベルの差はあれど、まったく同じ研究をやったと言うことで、たまらなく誇らしいような、いや、それはただの偶然の一致で・・・ 一方は AI というカタチで見事にモノになり、僕のは無駄な努力で終わり・・・もし、プログラムが当時のまま、今後進化しないのであれば・・・ みたいな複雑な気持ちではありますが )、いずれにしても、その時、僕は Google 先生を頼りに『 機械学習の真似事 』を行い、右も、左も、わからないまま、結局 keras や Lobe のお近づきになれたよーな・・・ なれなかったよーな・・・

日々を過ごしたことだけは、事実。( 2022年、春 )

で、結論だけ言うと、お遊び程度に使える自動採点機能を搭載したプログラムが書けました。・・・ただ、書けたことは書けたのですが、使用したライブラリが TensorFlow で、これには 32 ビット版がなく、仕方がないからプログラムは無理して 64 ビット化して作成。

その結果、 AC_Reader に同梱して使うその他のプログラム( My マークシートリーダー = MS_Reader.exe 等)が 32 ビット版であること、つまり、内部で共通に呼び出して使っている Embeddable Python も 32 ビット版であることから、 AC_Reader と My マークシートリーダーとが共存するには Embeddable Python を共用しなければならないというところが大問題に。結局、64 ビット版の AC_Reader は使用を断念。版を 32 ビットに戻すと同時に、64 ビット版の AC_Reader に搭載した自動採点機能は、32 ビット版で泣く泣く削除。

あれから2年間。AC_Reader は、ほぼ、放置状態。

(表計算ソフトを使わずに、成績一覧表を出力できるようにする等、採点に伴う作業を軽減できるよう、付属的なプログラムを新たに作成すると言った、おまけ的な面で多少の改善は加えましたが、手書き答案の採点という、本業面での進化は、よく使う機能を集めてフローティングパネル化した程度)

そう、せっかく Google 先生が評価してくれたのに、プログラム本体が2年間まったく進化していないことが、心から「やばい」と感じた理由なのです。

苦しかった、この2年間を、その理由にしてはいけないのですが・・・

それでも、僕を支えてくださった方々の要望には、何としても応えたいという思いがあり・・・

必死の思いで、過去記事「組み合わせ採点を実現したい!」に書いた内容を組み込んだ答案返却用答案(?)を作成・印刷する新しいプログラムを書き、採点現場での実地テストを無事終え、そちらを「ReportCard_2025」として公開すべく、準備を進めていたのですが、先に書いた検索結果を目の当たりにして、こちらをいったん中止。

AC_Reader を2年ぶりに進化させることに決めました。
内容はもちろん、自動採点機能の搭載です。

【もくじ】

1.32ビット版で自動採点機能を搭載できないか?
2.Tesseract-OCR を使う
3.scikit_learnを使う
 (1) Embeddable Python へのインストール
 (2) 学習モデルを作成して認識テスト
4.とんでもない認識結果に驚愕する
5.まとめ
6.お願いとお断り

1.32ビット版で自動採点機能を搭載できないか?

Delphi もバージョン 12.3 では「 RAD Studio 12 ( 64-bit Initial Release ) 」がついに登場。機械学習の現場でも 64 ビット化はさらに加速しつつあり、今更、32 ビットにこだわる必要などないと自分でも思うし、64ビット化の流れに反対する気持ちなどまったくないが・・・

ただ、これまでに書いてきたプログラムをすべて64ビット化するのは大変だし、その前に、32 ビット版に今すぐできる改良があるなら、それを行えば、より良いものをユーザーに提供できる可能性が 32 ビット版のプログラムにも、まだ残されている気が・・・

「 より良いもの 」・・・ それこそが 32 ビット版 AC_Reader への自動採点機能の搭載だと思いました。

あれから2年経過して、手書き文字認識や機械学習のプログラム自体も相当進化しているのではないかと考え、まず、思い出したのは Tesseract-OCR です。

2.Tesseract-OCR を使う

他にも思い出せるモノはたくさんあったんだけど、機械学習系は手書き文字の認識の前に、大量のデータを集めてトレーニングして・・・ といった学習(の手間)が必要なので、そういった手間のいらないところから搭載の可否を探ろうと思ったわけです。「寄らば大樹の陰・・・」みたいな。

手書き文字でない、既存の TrueType 日本語フォントに対してなら、Tesseract-OCR がどれほど素晴らしい性能を発揮するか、それは2年前に目の当たりにしています。ただ、残念ながら、手書き文字の認識といった部分では、2年前はお世辞にも良好とは言えなかったと記憶しています。

早速、最新版(?)をダウンロード( tesseract-ocr-w32-setup-v5.3.0.20221214.exe :これより新しい 32 bit版は探せなかった)して、実験してみました。日付が、ちょっと古いのが気になりましたが。もしかして、2年前もコレで実験した? みたいな感が・・・。

手書き文字は、次のような実験用サンプルを700個(すべて「ア」の画像)ほど用意。

一緒に暮らしている人が書いた「ア」


実験に使った Python スクリプトは、コレ!
画像から抽出する文字は「アイウエオ」の中の1字。画像が「ア」であると判定すれば「ア」を出力、「アイウエオ」のいずれでもない(=判定不能である)場合は「N」を出力する。

import cv2
import pytesseract
import re
import os

# Tesseract-OCRのパス設定
pytesseract.pytesseract.tesseract_cmd = r"C:\Python39-32\Tesseract-OCR\tesseract.exe"

def preprocess_image(image_path):
    """ 画像を前処理してOCRに適した状態にする """
    # グレースケール化
    image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
    # 二値化  
    _, binary = cv2.threshold(image, 128, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)  
    return binary

def extract_katakana(image):
    """ OCRでカタカナを認識する """
    custom_oem_psm = "--oem 3 --psm 10 -l jpn"
    text = pytesseract.image_to_string(image, config=custom_oem_psm)

    # カタカナ1文字のみを抽出
    # match = re.search(r'[アイウエオ]', text)
    return match.group(0) if match else "N"

def process_images_in_folder(folder_path):
    """ 指定フォルダ内のすべての画像を処理 """
    image_extensions = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
    for filename in os.listdir(folder_path):
        # 画像ファイルのみ処理
        if filename.lower().endswith(image_extensions):  
            image_path = os.path.join(folder_path, filename)
            processed_image = preprocess_image(image_path)
            result = extract_katakana(processed_image)
            print(f"{filename}: OCR結果 -> {result}")

if __name__ == "__main__":
    # 画像が入っているフォルダのパス
    folder_path = "Images_Tegaki\img1_a"  
    process_images_in_folder(folder_path)

結果は次の通り。

画像は、全部カタカナの「ア」なんだけどなー。
「N」はともかく、なんで「イ」があるのかなー?


全体の集計では・・・


正解率は 23.3 % ・・・

ただ、「ウ・エ・オ」はありませんでした。そこで・・・

match = re.search(r'[ア]', text)

「ア」1文字で勝負してみました。結果はまったく同じでありました!

よくよく考えれば、同じ文字認識アルゴリズムで「ア」を判定しているのですから、これは当然です。

64 bit バージョンの方は最新版が「最近の日付」でしたから、これより良い結果が得られる可能性があるような気がしますが、僕が使いたい 32 bit バージョンに限っての話をしていますので、この時点で手書き文字の認識に Tesseract-OCR の 32 bit バージョンを使用するか、否か、という問題は、はっきり「 否 」と答えが出ました。

過去の記事にも書きましたが、これは「手書き文字の認識(それも「ア」1文字)」に限った話であり、他のカタカナ文字については実験もしておりませんし、これを持って、Tesseract-OCR 32 bit バージョンの総合的な「手書き文字」を認識する性能を否定する意図はまったくありません。

日本語 TrueType フォントの書体であれば、Tesseract-OCR は十分実用的な精度で文書をテキスト化してくれる素晴らしいプログラムです!!

3.scikit_learnを使う

(1) Embeddable Python へのインストール

次に思い出したのが keras だったのですが、2年前の実験における手書きカタカナ文字「アイウエオ」の認識率は 95 ~ 97 %程度(文字によって差がある)で、これ以上はどう頑張ってもダメだった記憶が同時に蘇り・・・

AI に聞いてみると、「 keras も進化してます!」とのことでしたが、ここで、ふと、思い立ち、

「 32 bit で動作するプログラムで、手書き文字認識が可能な Python で動作するオープンソースの機械学習ライブラリは何?」と尋ねてみると・・・

scikit-learn です!

・・・との答えがトップに表示されました。

( scikit-learn ・・・ )

scikit-learn は2年前にも試していません。名前は聴いたことがあったような気がしますが・・・

AI の説明には、心揺さぶられるような文言が並び!!!

曰く、軽量で依存が少ない。
曰く、古いマシンでも動作しやすい。

さらに・・・

「SVM(サポートベクターマシン)などでの文字認識は、軽量で精度も悪くないです。」

とのこと。

サポートベクターマシンってのが、よくわからなかったので、さらに質問して見ると・・・

「サポートベクターマシン(SVM:Support Vector Machine)」は、分類や回帰に使える機械学習のアルゴリズムの一種で、scikit-learn が得意なことは、「はっきりと分けられる2つのクラス分類」であるとのこと。まさに「手書き文字認識」のためにあるようなライブラリ。何で2年前、scikit-learn を試さなかったのか・・・。後悔先に立たず。試さなかった事実は事実。それは認めるしかない。でも、今、僕は、まだ、生きていて、あの頃は読めなかった AI のアドバイスを、今、読んでる・・・

「他のライブラリにほぼ依存せず、古いPCでも動き、軽量で、精度も悪くない。」

だんだん、だんだん、AI の言うことを信じて、動かしてみたい気になってきました☆

※ ちなみに「回帰」もわからなかったので調べて見ると、「 回帰(Regression)」は、予測したい結果が “数値” のときに使う機械学習の手法であるとのこと。「分類(Classification)とセットでよく出てくる」言葉なんだそうです。確かに、どこかで何度も目にしたことがあるような・・・。今、僕がやりたいのは「分類(Classification)」の方ですが、大変、勉強になりました!!

とりあえず、scikit-learn を入手して、それをインストールしなければ話は始まらない。

scikit-learn をインストールする予定の Embeddable Python を入れた Python39-32 フォルダをデジタル採点関係のプログラムを保存しているフォルダから、C:¥へコピーする。

ちなみに Python39-32 の 39 は Python のバージョン、32 は 32 bit 版という意味です。

なんでそんなことをしたかというと、Pathを短くするため。Python関連のプログラムをいじる時は、コマンドプロンプトで作業するのでPathが出来るだけ短い方が作業しやすい。

そうしておいて、AI の力を借りて、scikit-learn の 32 bit 版を探します。(実際にはここでかなりの時間を loss しているのですが)その結果わかったことは「通常の pip install scikit-learn でのインストールは 32ビット環境では失敗することが多い」ということ。なので、より確実にインストール可能なWindows用ホイールファイル(=拡張子が whl のファイル)を探すことにしました。

【参考】Windows用ホイールファイル(.whl)
Pythonで使用されるパッケージ形式のひとつ。Pythonのライブラリやモジュールを効率的にインストールできるファイルで、次の特徴がある。

・事前にビルドされたパッケージなので、必要なコードや依存関係がすべて含まれている。
・ソースコードをビルドする必要がないため、Windows 環境でのインストールが簡単になる。
・pip でインストールできる。
 例: pip install scikit_learn-0.24.2-cp39-cp39-win32.whl

予想通り、世の中は 64 bit 版へ移行しつつあり、scikit-learn の 32 bit 版の最新版は「2021年4月28日」の日付がある「scikit_learn-0.24.2-cp39-cp39-win32.whl」のようです(違うかもしれません)。

以下、実際に僕が行ったインストール作業の様子です。

cp39 だから Python3.9.X に対応しており、win32 だから 32 bit 対応版であることがわかる。検索したらいちばん上に「 Pypl 」の「 scikit-learn 0.24.2 」が表示された。リンクをたどって、https://pypi.org/project/scikit-learn/0.24.2/ へ行き、さらにページの左側にある「ファイルをダウンロード」をクリックしてダウンロードページへ行き、Built Distributions の上から2番目に目的の「scikit_learn-0.24.2-cp39-cp39-win32.whl」を発見。これをダウンロードして、Python39-32 フォルダへコピーする。

コマンドプロンプトを起動していちばん最初に行うことは、この場合、pip のアップデートだ。Embeddable Python に Numpy や OpenCV をインストールした時、Embeddable Python で pip を使う方法の詳細なメモを残しておいたので、それを見ながら作業する。

C:\>cd Python39-32

C:\Python39-32>python -m pip install --upgrade pip
Requirement already satisfied: pip in c:\python39-32\lib\site-packages (22.3.1)
Collecting pip
  Using cached pip-25.0.1-py3-none-any.whl (1.8 MB)
Installing collected packages: pip
  Attempting uninstall: pip
    Found existing installation: pip 22.3.1
    Uninstalling pip-22.3.1:
      Successfully uninstalled pip-22.3.1
  WARNING: The scripts pip.exe, pip3.9.exe and pip3.exe are installed in 'C:\Python39-32\Scripts' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed pip-25.0.1

僕のはもう設定してあるから、次の作業は不要だけれど、必要な方がいるかもしれないので参考までに書くと・・・ まずは、Embeddable Python で pip を使えるようにする方法。

デフォルトの python.exe では import site が無効になっているため、外部ライブラリをインポートできない。

解決策: python._pth を編集する
    python._pth(python.exe と同じフォルダにある)を開く
    #import site のコメントアウトを解除(# を削除)

# python36.zip
# ./DLLs
# ./Lib
# ./Lib/site-packages
import site  # ← コメントアウトを外す
# Uncomment to run site.main() automatically

さらに、pip を有効化するために次の作業も行う。

pip は Embeddable Python には入っていないので、次の方法で pip を使えるようにする。

(1) get-pip.py をダウンロード
    get-pip.py を 公式サイト(https://bootstrap.pypa.io/get-pip.py)からダウンロード
    C:\Python39-32(僕の場合) に配置

(2) pip をインストール
C:\Python39-32\python.exe get-pip.py

(3) pip でライブラリをインストール
C:\Python39-32\python.exe -m pip install requests

あと、環境変数を設定するには・・・

set PYTHONHOME=C:\Python39-32
set PYTHONPATH=C:\Python39-32\Lib
C:\Python-Embed\python.exe XXX.py  # <-Pythonスクリプトの実行

ここまで行えば、pip が使えるので、ダウンロードした scikit_learn-0.24.2-cp39-cp39-win32.whl のインストールが可能になる。

後で Python スクリプトも実行するので、環境変数の設定も行いつつ・・・

C:\Python39-32>set PYTHONHOME=C:\Python39-32
C:\Python39-32>set PYTHONPATH=C:\Python39-32\Lib
C:\Python39-32>set PYTHONPATH=C:\Python39-32\Scripts  # <-効いてない気がするが・・・

ただ、ここでいきなり scikit_learn をインストールしようとすると失敗する。

C:\Python39-32>python.exe -m pip install C:\Python39-32\scikit_learn-0.24.2-cp39-cp39-win32.whl
Processing c:\python39-32\scikit_learn-0.24.2-cp39-cp39-win32.whl
Requirement already satisfied: numpy>=1.13.3 in c:\python39-32\lib\site-packages (from scikit-learn==0.24.2) (1.21.5)
Collecting scipy>=0.19.1 (from scikit-learn==0.24.2)
  Using cached scipy-1.13.1.tar.gz (57.2 MB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
ERROR: Exception:
Traceback (most recent call last):
  File "C:\Python39-32\lib\site-packages\pip\_internal\cli\base_command.py", line 106, in _run_wrapper
    status = _inner_run()
  File "C:\Python39-32\lib\site-packages\pip\_internal\cli\base_command.py", line 97, in _inner_run
    return self.run(options, args)
  File "C:\Python39-32\lib\site-packages\pip\_internal\cli\req_command.py", line 67, in wrapper
    return func(self, options, args)
  File "C:\Python39-32\lib\site-packages\pip\_internal\commands\install.py", line 386, in run
    requirement_set = resolver.resolve(
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\resolver.py", line 95, in resolve
    result = self._result = resolver.resolve(
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\resolvers.py", line 546, in resolve
    state = resolution.resolve(requirements, max_rounds=max_rounds)
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\resolvers.py", line 427, in resolve
    failure_causes = self._attempt_to_pin_criterion(name)
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\resolvers.py", line 239, in _attempt_to_pin_criterion
    criteria = self._get_updated_criteria(candidate)
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\resolvers.py", line 230, in _get_updated_criteria
    self._add_to_criteria(criteria, requirement, parent=candidate)
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\resolvers.py", line 173, in _add_to_criteria
    if not criterion.candidates:
  File "C:\Python39-32\lib\site-packages\pip\_vendor\resolvelib\structs.py", line 156, in __bool__
    return bool(self._sequence)
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\found_candidates.py", line 174, in __bool__
    return any(self)
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\found_candidates.py", line 162, in <genexpr>
    return (c for c in iterator if id(c) not in self._incompatible_ids)
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\found_candidates.py", line 53, in _iter_built
    candidate = func()
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\factory.py", line 187, in _make_candidate_from_link
    base: Optional[BaseCandidate] = self._make_base_candidate_from_link(
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\factory.py", line 233, in _make_base_candidate_from_link
    self._link_candidate_cache[link] = LinkCandidate(
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\candidates.py", line 304, in __init__
    super().__init__(
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\candidates.py", line 159, in __init__
    self.dist = self._prepare()
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\candidates.py", line 236, in _prepare
    dist = self._prepare_distribution()
  File "C:\Python39-32\lib\site-packages\pip\_internal\resolution\resolvelib\candidates.py", line 315, in _prepare_distribution
    return preparer.prepare_linked_requirement(self._ireq, parallel_builds=True)
  File "C:\Python39-32\lib\site-packages\pip\_internal\operations\prepare.py", line 527, in prepare_linked_requirement
    return self._prepare_linked_requirement(req, parallel_builds)
  File "C:\Python39-32\lib\site-packages\pip\_internal\operations\prepare.py", line 642, in _prepare_linked_requirement
    dist = _get_prepared_distribution(
  File "C:\Python39-32\lib\site-packages\pip\_internal\operations\prepare.py", line 72, in _get_prepared_distribution
    abstract_dist.prepare_distribution_metadata(
  File "C:\Python39-32\lib\site-packages\pip\_internal\distributions\sdist.py", line 56, in prepare_distribution_metadata
    self._install_build_reqs(finder)
  File "C:\Python39-32\lib\site-packages\pip\_internal\distributions\sdist.py", line 126, in _install_build_reqs
    build_reqs = self._get_build_requires_wheel()
  File "C:\Python39-32\lib\site-packages\pip\_internal\distributions\sdist.py", line 103, in _get_build_requires_wheel
    return backend.get_requires_for_build_wheel()
  File "C:\Python39-32\lib\site-packages\pip\_internal\utils\misc.py", line 702, in get_requires_for_build_wheel
    return super().get_requires_for_build_wheel(config_settings=cs)
  File "C:\Python39-32\lib\site-packages\pip\_vendor\pyproject_hooks\_impl.py", line 196, in get_requires_for_build_wheel
    return self._call_hook(
  File "C:\Python39-32\lib\site-packages\pip\_vendor\pyproject_hooks\_impl.py", line 402, in _call_hook
    raise BackendUnavailable(
pip._vendor.pyproject_hooks._impl.BackendUnavailable: Cannot import 'mesonpy'

最初に コレ を見たときは マジ 泣きたくなった・・・ T_T

いろいろ調べて見ると、どうやら最後に出てくる MesonPy に原因があるらしい。scikit_learn と同時にインストールされる scipy には mesonpy というビルドツールが必要で、それが 32ビット環境では動作しないことがエラーの原因とのこと。どうやら MesonPy は 32 bit 版に対応していないようだ。じゃあ、どうするかと言うと、最初に scipy を単体でインストールする。

次のサイトにアクセスし、Python 3.9 (32bit) 対応の scipy の .whl をダウンロードする。

https://www.lfd.uci.edu/~gohlke/pythonlibs/#scipy

上のサイトに「scipy-1.9.0-cp39-cp39-win32.whl」があったので、これをダウンロードして、Python39-32 フォルダへコピーする。で、pip を使ってインストールする。

C:\Python39-32>python.exe -m pip install C:\Python39-32\scipy-1.9.0-cp39-cp39-win32.whl
Processing c:\python39-32\scipy-1.9.0-cp39-cp39-win32.whl
Requirement already satisfied: numpy<1.25.0,>=1.18.5 in c:\python39-32\lib\site-packages (from scipy==1.9.0) (1.21.5)
Installing collected packages: scipy
Successfully installed scipy-1.9.0

次に scikit_learn をインストールする。

C:\Python39-32>python.exe -m pip install C:\Python39-32\scikit_learn-0.24.2-cp39-cp39-win32.whl
Processing c:\python39-32\scikit_learn-0.24.2-cp39-cp39-win32.whl
Requirement already satisfied: numpy>=1.13.3 in c:\python39-32\lib\site-packages (from scikit-learn==0.24.2) (1.21.5)
Requirement already satisfied: scipy>=0.19.1 in c:\python39-32\lib\site-packages (from scikit-learn==0.24.2) (1.9.0)
Collecting joblib>=0.11 (from scikit-learn==0.24.2)
  Downloading joblib-1.4.2-py3-none-any.whl.metadata (5.4 kB)
Collecting threadpoolctl>=2.0.0 (from scikit-learn==0.24.2)
  Downloading threadpoolctl-3.6.0-py3-none-any.whl.metadata (13 kB)
Downloading joblib-1.4.2-py3-none-any.whl (301 kB)
Downloading threadpoolctl-3.6.0-py3-none-any.whl (18 kB)
Installing collected packages: threadpoolctl, joblib, scikit-learn
Successfully installed joblib-1.4.2 scikit-learn-0.24.2 threadpoolctl-3.6.0

ちょっとたいへんだったけど、これでなんとか、scikit_learn の 32 bit 版が Embeddable Python にインストールできた!!( Python39-32 フォルダのサイズが 335 MB になっちゃったけど、これだけはもうどうにもならない。ちなみに Tesseract-OCR を入れた場合は、その倍くらいになった!)

(2) 学習モデルを作成して認識テスト

2年前の手書きカタカナ文字認識チャレンジで使った手書きカタカナ文字の画像ファイルは、壊れたノートパソコンから取り外した SSD を専用ケースに入れて作った外付け SSD ドライブに保存してある。

その SSD ドライブ内を検索し、テストで使えそうな画像ファイルを探すと、ア・イ・ウ・エ・オの各文字がほぼ 700 字ずつ、フォルダに分類されて保存されているのを見つけることができた。

( あった。コレだ )

記憶では「水増し」して 3000 文字くらいずつ集めたフォルダもあったはずだが、文字数が増えれば増えるほどコピーに時間がかかる。それに、いきなり 3000 文字を機械学習させて結果が失敗だったら、その後、打つ手がなくなる・・・。だから、とりあえず、この 700 字でテストしてみようと考えた。

2年前は手書きカタカナ文字の収集や整理に膨大な時間を要したが、今回は「それがない」から、何の苦労もなく仕事はスイスイ進む。

scikit_learn の学習モデルを作成するスクリプトに合うよう、画像ファイルを入れたフォルダを準備して学習モデルを作成する。そのスクリプトがコレです。

import cv2
import numpy as np
from sklearn import svm
from sklearn.model_selection import train_test_split
import os
import joblib  # モデルの保存と読み込みに使用

from sklearn.svm import SVC  # SVMにクラスの重みを追加することで、少数派クラスに対して重みを高く設定

# カタカナのクラス(修正: 「ア」を追加)
CATEGORIES = ["ア", "イ", "ウ", "エ", "オ"]

# Pathの中の日本語に対応
def imread(filename, flags=cv2.IMREAD_GRAYSCALE, dtype=np.uint8):
    try:
        n = np.fromfile(filename, dtype)
        img = cv2.imdecode(n, flags)
        return img
    except Exception as e:
        print(e)
        return None

# データセットの準備(28x28 の手書きカタカナ画像)
def load_images_from_folder(folder, categories):
    images = []
    labels = []
    for label, category in enumerate(categories):
        path = os.path.join(folder, category)  # パスの結合方法を修正
        print(f"Processing category: {category}, Path: {path}")  # デバッグ用に出力

        # ディレクトリが存在するか確認
        if not os.path.exists(path):
            print(f"Warning: Path does not exist: {path}")
            continue

        for file in os.listdir(path):
            # ファイルが画像であるかどうかを拡張子でチェック
            if file.lower().endswith(('.png', '.jpg', '.jpeg')):
                file_path = os.path.join(path, file)
                # print(f"Trying to load file: {file_path}")  # 読み込みファイルのパスを表示
                try:
                    # カタカナを含むパスが問題ないかを確認
                    # img = cv2.imread(file_path, cv2.IMREAD_GRAYSCALE)
                    img = imread(file_path)
                    if img is not None:
                        img = cv2.resize(img, (28, 28))
                        images.append(img.flatten())  # 1次元化
                        labels.append(label)
                    else:
                        print(f"Failed to load image: {file_path}")
                except Exception as e:
                    print(f"Error loading {file_path}: {e}")
            else:
                print(f"Skipping non-image file: {file}")
    print(f"Loaded {len(images)} images")
    return np.array(images), np.array(labels)

# データ読み込み
X, y = load_images_from_folder(r"C:\Python39-32\Images_tegaki\img_28", CATEGORIES)
X = X / 255.0  # 正規化

# データがロードされていない場合にエラーを出す
if len(X) == 0:
    raise ValueError("No images loaded. Please check the image files and paths.")

# 学習とテストの分割
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# SVM モデルの作成と学習
model = svm.SVC(kernel='linear')
model.fit(X_train, y_train)

# SVM モデルの作成と学習(クラスの重みを設定する)
# class_weights = {0: 1, 1: 2, 2: 2, 3: 1, 4: 1}  # イとウの重みを増やす
# model = SVC(kernel='linear', class_weight=class_weights)
# model.fit(X_train, y_train)

# モデルを保存する
joblib.dump(model, 'katakana_svm_model.pkl')
print("Model saved as 'katakana_svm_model.pkl'")

# 予測関数
def preprocess_image(image_path):
    img = imread(image_path)
    h, w = img.shape

    # 正方形になるように余白を追加
    size = max(h, w)
    square_img = np.full((size, size), 255, dtype=np.uint8)  # 背景を白に
    x_offset = (size - w) // 2
    y_offset = (size - h) // 2
    square_img[y_offset:y_offset + h, x_offset:x_offset + w] = img

    # 28x28 にリサイズ
    img_resized = cv2.resize(square_img, (28, 28))
    return img_resized.flatten() / 255.0

def predict_character(image_path):
    img = preprocess_image(image_path)
    model = joblib.load('katakana_svm_model.pkl')  # 学習したモデルをロード
    label = model.predict([img])[0]
    return CATEGORIES[label]

# テスト画像の認識ア
image_path = "katakana_sample_A.jpg"
result = predict_character(image_path)
print(f"認識結果: {result}")

# テスト画像の認識イ
image_path = "katakana_sample_I.jpg"
result = predict_character(image_path)
print(f"認識結果: {result}")

# テスト画像の認識ウ
image_path = "katakana_sample_U.jpg"
result = predict_character(image_path)
print(f"認識結果: {result}")

# テスト画像の認識エ
image_path = "katakana_sample_E.jpg"
result = predict_character(image_path)
print(f"認識結果: {result}")

# テスト画像の認識オ
image_path = "katakana_sample_O.jpg"
result = predict_character(image_path)
print(f"認識結果: {result}")

このスクリプトで学習モデルを作成し、最後に別に用意したテスト画像を認識させてみた。

「ア・イ・オ」は、いっしょに暮らしている人が、
「エ・ウ」は、僕が書いた手書きカタカナ文字。

結果は、とても不思議なことに「ア・エ・オ」は正しく読み取るのだが、「イ・ウ」を間違えてしまって、なんだか Python に混乱が生じているような感じ。

そこで行ったことが学習する際の重み付けの変更。その跡が上のスクリプトの赤字となっている。

で、重み付けを変更して(イ・ウの重みを増加させて)新たに学習モデルを作成し、テストしてみるが結果は第1回目と同様。「ア・エ・オ」は正しく読み取るのだが、「イ・ウ」を間違えてしまう。

何気なく「アイウエオ」の各文字を保存したフォルダを開けて見て、ようやく原因が判明。なんと「ウ」のフォルダ内に「ウ」はなく、「イ」が溢れかえって・・・

つまり、コピーする際、僕が間違えて・・・

うぎゃ!Zoräth ✷ fel∅, ∞’ka selenïv! ⧖ Trål’xon que!

(T▽T;) やっちまったぁ!!

手書きカタカナ文字を正しく分類し直して、再度、機械学習を実行し、学習モデルを作成。

今度は・・・

やった! やった!!

4.とんでもない認識結果に驚愕する

次に、学習用に使った「アイウエオ」各 700 文字で読み取りテストをやってみる。できれば、学習用に使ってない文字がよかったんだけど、残念ながらそれはないので、学習用素材でテストを強行。

各文字の認識率は、次の通り。

まず、「ア」


次、「イ」


次、「ウ」


次、「エ」


次、「オ」


事前に学習に使ってるから、ある意味「不正行為」と言えなくもないんだけど・・・

これなら手書き文字認識に
十分、使えるのでは
ないでしょうか?

さぁ AC_Reader の改造だ!

5.まとめ

・scikit-learn で作成した学習モデルは、宝物になりそうだ☆☆☆

6.お願いとお断り

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