Programming」カテゴリーアーカイブ

Delphiによるプログラミング関係のTips

マウスだけで操作可能な画面の拡大表示ツール “KindLens” のご紹介

「見る力を、やさしく支える」KindLens — ドラッグとクリックだけで、視界のバリアを取り払います。

【もくじ】

1.機能のご紹介
2.ダウンロード
3.お願いとお断り

追記(20250715)

初期バージョンにあった不具合を解消しました。主な改善点は以下の通りです。

(1)矢印型Formの画面上の位置に応じて、矢印の向きとキャプチャ範囲を自動設定します。
(2)矢印形状(方向)のリアルタイム描画で、より直感的なキャプチャ範囲設定を可能としました。
(3)キャプチャ画面を指定時間後に自動で閉じる機能を追加しました(0.5秒刻みで設定可)。

1.機能のご紹介

このプログラムは、重い障害のある方が右手でトラックボールマウスを操作して、動画・静止画を問わず、画面上に表示されている細かな文字や図を拡大表示して読めるように Delphi の力を借りて開発したものです。利用規約及び使用条件に同意していただければ、どなたでも無料でお使いいただけます。

PCの画面を拡大表示できるツールは Windows の拡大鏡をはじめとしてさまざまなものがありますが、各種設定変更の必要性がなく、単一の実行形式ファイルのダブルクリックで起動し、マウス操作(ドラッグ&ドロップと左ボタンクリック)のみで画面の拡大表示を実現できる無料ツールはおそらくないのではないかと思います。

使い方は・・・

【初期バージョン】※ 現在、ダウンロードできません。

(1)矢印型の Form を拡大表示したい領域の右下へドラッグして移動します。
(2)ドロップした矢印型 Form 上をクリック(マウスの左ボタンを押し下げ)します。
(3)ドロップした位置の左上方向の画面が、拡大表示されます。

【改良バージョン】

矢印型のFormを拡大表示したい領域へドラッグすると、その位置に応じて矢印の形状(指し示す方向)が変化しますので、拡大表示したい領域(の右上・左上・左下・右下位置)へドロップすると、自動的にキャプチャされた範囲が、予め設定した大きさで表示されます。

拡大したい領域の右上・左上・左下・右下のどこにドロップしたらよいのかは、矢印の形状から直感的に判断できるように改善しました。また、改良版では、設定画面の表示位置も画面上の矢印位置に応じ、最適な位置に表示されるようになっています。

拡大倍率は、デフォルト設定2倍です。表示窓の大きさは、デフォルト設定で幅640ピクセル、高さ320ピクセルです。これらの設定は任意の値への変更と、変更した状態の保存が可能です(ただし、簡単にデフォルト設定に戻す機能は用意してありません — 少々乱暴ですが、exe と同じ場所にある exe と同じ名前の拡張子が ini のイニシャライズファイルを削除すれば、デフォルト設定に戻ります — が、その必要がある場合は、この記事のパラメータ設定をご参照ください)。

言葉で表現してもイメージしにくいと思いますので、実際の実行例をご覧ください。

拡大表示したい範囲の右下へ矢印型Formをドラッグして移動します。


矢印型 Form 上(内部)をクリック(マウスの左ボタン押し下げ)します。すると、次のように矢印方向左上の画面が拡大表示されます。※ 改良版では、矢印型 Form のドロップと同時に拡大表示されます。

拡大表示された別窓(窓枠はありません)をクリックすれば表示は消えます。
改良版では、指定時間経過後に自動的に消去する設定も可能です。
(ESCキー押し下げでも拡大表示は消えます)


ESCキー押し下げによる拡大表示の消去機能は、当初、計画したプログラムの仕様にはありませんでしたが、このプログラムをお使いになる方の左側に、介助される方がいらっしゃるような場合には役立つことがあるかもしれないと考え、実装しました。

また、矢印型の Form は、常に最前面に表示されますので、動画等を全画面表示している場合でも問題なく動作します。拡大表示は矢印型の Form 上をクリックすることで実行されますので、動画アプリの操作と干渉することはありません(動画を流したまま、その一部の拡大表示が「静止画」として可能です)。

私のPC環境では、TEAMSで配信した動画や、YouTube の動画は静止画として拡大表示できましたが、PC環境や通信方法によっては動画を静止画として取得できない場合があるかもしれません。また、このプログラムは Microsoft 社の Windows11 で開発し、同 OS 上で動作確認を行っています。他社製 OS 上での動作は未確認ですので、間接的な方法やエミュレーション技術を利用されて本プログラムを Windows 以外の OS 上で実行される場合は、プログラムそのものが動作しない可能性があることに十分ご注意ください。

【プログラムの開発環境(ご参考まで)】

・デバイスの仕様

 デバイス名	XXX
 プロセッサ	11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz (3.00 GHz)
 実装 RAM	32.0 GB (31.7 GB 使用可能)
 デバイス ID	
 プロダクト ID	
 システムの種類	64 ビット オペレーティング システム、x64 ベース プロセッサ
 ペンとタッチ	10 タッチ ポイントでのペンとタッチのサポート

・Windowsの仕様

 エディション	Windows 11 Pro
 バージョン	24H2
 インストール日	‎2024/‎10/‎05
 OS ビルド	26100.4652
 エクスペリエンス	Windows 機能エクスペリエンス パック 1000.26100.128.0

・開発環境

 Embarcadero® Delphi 12.3 (バージョン 29.0.55362.2017)
 Professional with Mobile

当初は、拡大対象が動画であった場合は、拡大表示の映像も動画そのものをリアルタイムで拡大して表示する実装でプログラミングしていたのです・・・が、よくよく考えますと、このプログラムは、その前提として、定点に固定されたビデオカメラで写した映像を視聴する場合を想定しており、特に、「ビデオカメラ自体による画面のズームや向きの変更がない」状態で配信された映像中の文字や図表等を確認したい場合のヘルパーとして役立つ(使える)ように開発しましたので、「読めること」を何よりも優先し、動画も静止画として拡大表示する実装に途中から設計方針を変更しました。

また、高齢の方や障害のある方にもやさしい GUI となるよう、矢印型 Form の大きさはデフォルト設定では、やや大きめに設定してあります。これはさらに大きくしたり、より小さくしたり、お使いになる方のお好みの大きさに設定から変更可能です。

【設定の変更方法】

設定を変更するには、矢印型の Form 上を右クリック(マウスの右ボタン押し下げ)して、表示されるサブメニューから「設定」を選んでクリックします。

「閉じる」をクリックするとプログラムは終了します。


設定画面が次のように表示されます。サイズはデフォルト設定を100(%)としてあります。その他の項目は直感的に意味を御理解いただけると思います。

実行時のイメージをキャプチャしました。


矢印型 Form の色と大きさを変更してみました。

色を水色に、サイズは40%に縮小してみました。


矢印の色に「白」を設定した場合は、矢印の輪郭を黒で描画して白背景の画面でも矢印 Form の位置がわかるように工夫してあります。ただし、白以外の淡色を指定した場合は、このような黒い輪郭の描画は行われません。くれぐれもご注意ください

色を「白」に設定した場合、矢印の輪郭が黒い線で描画されます。


もし、矢印 Form の色を白以外のごく薄い淡色に設定して、矢印型 Form の表示位置がわからなくなった場合は、タスクバーに表示されている KindLens のアイコンを右クリックして表示されるメニューから「ウィンドウを閉じる」を選択(クリック:マウスの左ボタン押し下げ)する方法で、プログラムを終了することができます。

設定状態を保存していない場合は、次回起動時には矢印型 Form の色は以前の状態に戻ると思いますが、ごく薄い淡色の設定状態を保存した場合は、KindLens.exe と同じ場所にある KindLens.ini を削除してから KindLens.exe を起動してください。矢印型 Form は初期設定の赤い状態で表示されます。その後、必要に応じて各種設定を変更してください。設定変更後、「保存」ボタンをクリックすれば、拡張子が ini のイニシャライズファイルが再作成され、新しい設定がこのファイルに保存されます。

タスクバーのアイコンを右クリックした状態です。


上の図で、本来「 KindLens 」と表記されるはずの部分が「 Project1 」と表示されているのは、開発環境である Delphi の最初の保存時のプロジェクトファイルの名称が「 Project1 」であったためかと思われます。

開発が軌道に乗った後、プロジェクトファイルの名称を「 KindLens.dproj 」に変更したのですが、この部分の表記は変更されませんでした。これは Delphi の仕様かと思われます。

【設定の保存】

設定値の変更をデフォルト状態として保存します。


設定画面左下の「保存」ボタンをクリックすれば、イニシャライズファイルに設定内容が記録され、次回起動時は保存された設定内容に従って起動します。

「保存」ボタンをクリックすると、KindLens.exe と同じ場所に KindLens.ini が自動的に作成されます。この拡張子が ini のファイルは設定が記録されているファイルなので、誤って消去しないよう十分にご注意ください(ini ファイルを消去した場合、プログラムはデフォルト設定で起動します)。

また、キャプチャする幅と高さは任意の値を直接指定するか、ComboBox の選択肢から選べます。

//キャプチャ画面の幅と高さの実装コード
cmbCW.Items.AddStrings(['240', '320', '400', '480', '560', '640', '720', '800']);
cmbCH.Items.AddStrings(['240', '320', '400', '480', '560', '640', '720', '800']);

上のコードからわかる通り、選択肢から選べる値は、80の倍数としてあります(80の倍数とした理由は特にありません。480とか、640という数字に対して、僕がうまく言葉に出来ない懐かしさを感じることが、その最大の理由であるように感じます)。

この KindLens と題したプログラムには、自分で考えた最低限の「あったらいいな!」と思う機能はすべて搭載しましたが、今後、実際に使用して判明した問題点等があれば速やかに改善したいと思います。その際は、この blog にバージョンアップ版を掲載いたします。

KindLens に関する操作の説明は以上です。たいへん申し訳ありませんが、取扱説明書やヘルプファイルの準備はございません。仕様・操作方法の説明につきまして、その必要がありましたら、この記事をご参照いただけますよう、お願いできましたら(また、ご案内等いただけましたら)幸いです。

2.ダウンロード

KindLens は、次のリンクからダウンロード可能です。ただし、ご利用に当たっては、利用規約及び使用条件への同意が必要です。

ダウンロード後、ダウンロードした KindLens.zip を右クリックし、表示されるサブメニューから「すべて展開」を選んでクリックしてください。次の画面が表示されます。

展開する場所を指定される場合は「参照」をクリックして、任意のフォルダを指定します。
zip ファイルと同じ場所に展開する場合は、そのまま「展開」をクリックしてください。


無事、ファイルが展開(いまだにこの表現に慣れません。どうしても解凍と言いたくなります)されると、次の3つのファイルがエクスプローラーに表示されます。

Windowsの設定によっては、exe 等の拡張子は表示されません。
(License.txt の内容は必ずご確認ください)


ルーペの中にハートの描かれたアイコンが KindLens の実行形式ファイル( exe )です。

このファイルをダブルクリックしてプログラムを起動してください。
(いきなりダブルクリックせず、このアイコンを右クリックすると表示されるサブメニューから「プロパティ」を選んで、Windows Defender SmartScreen による警告画面の表示を回避することもできます:後述)

【初回起動時に表示される警告について】

プログラムの起動に成功すると、初期状態では赤い矢印型 Form がお使いの PC の画面中央に表示されますが、ダウンロード&展開直後の最初の実行(プログラム起動)時には Windows の保護機能が働いて、次に示す Windows Defender SmartScreen による警告画面が表示されます。

「詳細情報」をクリックします。


次の画面が表示されます。

「実行」をクリックしてください。


これは KindLens.exe が悪意のあるプログラムであるために表示される警告ではなく、Windows に搭載されたセキュリティ機能に「未知の発行元や信頼性の低いファイルに対して警告を出す仕組みがある」ために表示されるものです。

ダウンロードされたファイルに「 Zone.Identifier 」という「ゾーン識別子」が付加されていると、Windows はこの識別子を見て「インターネット経由で取得されたファイル」と判断し、SmartScreen が警告を表示します。

【Windows Defender SmartScreen による警告画面を回避する方法】

ダウンロード&展開直後の最初の実行(プログラム起動)時には Windows の保護機能が働いて、上記 Windows Defender SmartScreen による警告画面が表示されますが、これを回避する方法もあります。以下、その手順です。

(1)KindLens.exe をいきなりダブルクリックせず、KindLens.exe のアイコンを右クリックすると表示されるサブメニューから「プロパティ」を選んでクリックしてください。

(2)次の画面が表示されますので、「全般」タブのいちばん下にある「セキュリティ:」部分を図に示した順にクリックしてください。

この作業を行うと、Zone.Identifier が削除され、警告は表示されなくなります。


上記作業を行うと、KindLens.exe に付加されていた「 Zone.Identifier 」という代替データストリーム(ADS)が削除され、警告の表示が出なくなります。「 Zone.Identifier 」はファイルの「本体(メインストリーム)」とは別の Windows の NTFS ファイルシステムの隠れた領域に付加されるメタ情報で、隠しストリームです。

上記のどちらかの方法で Windows の警告が表示される仕組みを解除してください。ADS の削除に成功すれば、次回の実行時から警告は表示されなくなります。

3.お願いとお断り

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

手書き答案の「デジタル採点補助プログラム」のつもりで作った僕のAC_Reader に自動採点機能みたいなモノを搭載しました!

今回ご紹介するプログラムで、自動採点できるかもしれない(?)手書き答案の解答は・・・

カタカナ「ア・イ・ウ・エ・オ」のいずれか1文字、それから
数字の「1・2・3・4・5」のいずれか1つ、そして
記号の「〇 ・ × 」のどちらかです。

この・・・ わずか 12 個の、文字・数字・記号に限定したお話ですが、僕が行ったテストでは各種パラメータの微調整を行うことなく、デフォルト設定のまま、テストデータ(少ないですが)をほぼ正しく推論できました。※ 制作の最終段階での検証結果です。

「自己責任・サポート無し」という条件付きですが、もし、よかったら、お試しください。

どなたにもお待ちいただいておりませんが、2年ぶりにバージョンアップした解答欄リーダーです。

【もくじ】

0.注意事項
1.論より証拠
2.自動採点機能の使い方
3.推論用画像データの確認
4.プログラムのダウンロード
5.お願いとお断り

【注意事項】

初回の自動採点実行時にPCがフリーズしたような状態になることがあります(正しく動作している状態であっても、Python Engine の初期化には数秒を要します)。特に、ダウンロードした Zip ファイルを展開(解凍)した直後の初めての実行時や、インターネット接続が切れた状態で使用した場合、この初期化作業にかなりの時間を要する場合があることを実際に確認しました(常に、この現象が起きるわけではありません)。この現象発生時に、内部的に呼び出して実行している組み込み Python 環境はエラーメッセージを出しません。つまり、プログラムは単に Python Engine の初期化完了を待つ「待機状態」であることは明らかなのです・・・ が、「プログラムで使用しているどのライブラリがこの待機状態を作り出しているのか」という、はっきりした原因の特定まで現在至っておりません。

この現象は、自動採点実行時、最初の1回に限って発生します。2回目以降は、採点終了まで滞りなく(素人が作ったプログラムなので実行速度は遅いですが)動作すると思います。

お試しいただける方には、たいへん申し訳ありませんが、そのような現象が発生することがあることをご理解いただいた上で、ご試用していただけますよう、伏してお願い申し上げます。

また、このプログラムの動作には「Microsoft Visual C ++ ランタイムライブラリ」のインストールが必要です。お使いのPCに「Microsoft Visual C ++ ランタイムライブラリ」が入っていない場合は、下記 Web サイトから「VisualCppRedist_AIO_x86_x64.exe」をダウンロードし、ダウンロードしたプログラムを管理者権限で実行し、動作に必要なライブラリをPCにインストールしてください。なお、インストール時には Windows のユーザーアカウント制御(UAC) が起動し、管理者用のID とパスワードの入力を求められます。インストールでは、exe の名称からわかるように 32 ビット版と 64 ビット版それぞれの VC++ランタイムライブラリがお使いの PC にセットアップされます。なお、インストール後は(僕のPC環境では)再起動なしで、そのまますぐに AC_Reader.exe を実行できました。

「VisualCppRedist_AIO_x86_x64.exe」の入手先:

https://www.majorgeeks.com/files/details/visual_c_redistributable_runtimes_aio_repack.html

2025年6月11日現在、バージョンは「0.91.0」でした。上記 Web サイトの Download (64-Bit EXE) というリンクをクリックすればインストールプログラムをダウンロードできます。

1.論より証拠

自動採点実行時の画面のハードコピーを以下に示します。なお、テスト用データの手書き「文字・数字・記号」は、すべて「お手本」を参照しながら、僕自身が「お手本」を真似て書いたものです。

まず、最初にカタカナの「アイウエオ」5文字の推論結果です。

正解ラベル:「ア」の場合です。(全体を表示するため、解答欄画像は縮小表示しています)

サンプル画像は、画面の表示倍率を81%に設定しているため、細部が霞んでいます。


正解ラベル:「イ」の場合です。


正解ラベル:「ウ」の場合です。


正解ラベル:「エ」の場合です。


No,1とNo,12の画像に縦方向の直線状の汚れがありますが、推論用画像作成の前段階の処理でその除去に成功しています(これを除去しておかないと、例えばNo,12の画像の推論用データは空白の画像ではなく縦線「|」が入った画像になり、学習モデルは間違いなくこれを「1」と推論してしまうはずです)。

解答欄の切り出し直後の画像では、No,1とNo,12の画像の左側に薄い灰色の直線状の汚れがあります。


推論用データ(文字の輪郭を検出して縦横28ピクセルの画像として解答欄の画像から切り出す)を作成する前段階で、これらの汚れを除去する処理を入れています。

No,1とNo,12の画像にあった汚れはキレイに消えています。


この「文字を消さずに汚れのみ除去する」処理はけっこう苦労しました。が、なんとか工夫を重ねて実現できました。「エ」の構成部品である「|」を消さずに、左側の汚れの「|」のみ除去するのは大変でしたが、線状の汚れと判断する基準にその高さ(長さ)を採用して、それが画像の高さとほぼ等しい場合は汚れと見なすことで、この問題はクリアできました。

以下、そのスクリプトです(ご参考まで)。

# 画像内の灰色の直線状汚れを除去

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

folder = r".\GrayLine"
image_extensions = ["*.png", "*.jpg", "*.jpeg"]
image_paths = []
for ext in image_extensions:
    image_paths.extend(glob(os.path.join(folder, ext)))

tolerance = 20  # 画像の高さとの誤差許容範囲(ピクセル単位)

for image_path in image_paths:
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        continue
    height, width = image.shape[:2]
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    edges = cv2.Canny(gray, threshold1=20, threshold2=80, apertureSize=3)
    lines = cv2.HoughLinesP(edges, rho=1, theta=np.pi / 180, threshold=50, minLineLength=50, maxLineGap=5)

    if lines is not None:
        for line in lines:
            x1, y1, x2, y2 = line[0]
            angle = np.degrees(np.arctan2(y2 - y1, x2 - x1))
            line_length = np.hypot(x2 - x1, y2 - y1)

            # 垂直線かつ画像の高さとほぼ同じ長さのみ除去
            if (abs(angle - 90) < 1 or abs(angle + 90) < 1) and abs(line_length - height) < tolerance:
                cv2.rectangle(image, (x1-5, 0), (x2+5, height), (255, 255, 255), 2)
                cv2.rectangle(image, (x1-4, min(y1, y2)-5), (x2+4, max(y1, y2)+5), (255, 255, 255), -1)

    cv2.imencode(".png", image)[1].tofile(image_path)

正解ラベル:「オ」の場合です。


カタカナ「アイウエオ」の5文字は間違えずに推論できました。イイ感じです。
次は数字の「12345」。何となくイケそうな気がしてきました☆

正解ラベル:「1」の場合です。


あ・れ・?

なんで「2」に〇が・・・

夢なら覚めてくれ・・・ T_T

一瞬。そう思いましたが・・・

大丈夫。転ぶのには慣れています。これまでだって さんざん・・・、

ここまで来て、あきらめるなんて、そっちの方が無理です。

急いで推論用の画像を確認。

何の問題もなく、解答欄画像からの切り抜きに成功している・・・


・・・ ということ は、学習データに問題があった ってコトか?

左へ 微妙に傾いているように見えます・・・

よくよく考えてみると、このような左に傾いた「2」は、利き腕が右の場合、なんとなく書きにくいような気もします。このことから、つまり、推論をミスした原因は、学習用データとして用意した画像の中に、左に傾いた「2」が少なかったため(?)ではないかと思えてきました。

見たところ、この「2」の画像には極端なシミも汚れもなく、色の濃さも十分、形状もちょっと縦に伸びてるかなって感じもしますが、まぁ、これは一般的にどう見ても「2」です。輪郭検出にも間違いなく成功して期待通りに切り出せている以上、やはり推論ミスの原因は「その傾きにある」としか思えません。

そこで・・・ ナニをしたかというと、

微妙に傾きの異なる画像を50枚作成


取りあえず、1~20°の範囲で、0.5°ずつ傾きに変化をつけ、推論をミスした「2」を左に回転させた画像を上のように50枚用意(処理する際に名称は関係ないので、ファイル名に一貫性はありません)して、さらに「2」の学習データは全体で約7000枚あるので、その1割にあたる700枚を抜き出し、ランダムに5°、10°、15°、20°のいずれかの角度で左に回転させ、先に用意した50枚と合わせて水増し学習データを合計750枚作りました。

「水増し」なんて言うと(文脈にもよりますが)どちらかと言えばネガティブな意味を含むことが多く、なんだか、とてもずる賢い・よからぬことをしているように感じますが、機械学習で使われる「水増し」という言葉は、 データ拡張(Data Augmentation) という概念を表すもので、基本的に悪い意味はないようです。むしろ、このテクニックは、モデルの汎化性能を向上させ、過学習(Overfitting)を防ぐために重要な技術とされているようです。十分な学習データがない場合に、画像の回転・拡大・ぼかし・ノイズ追加などを行うことで、実質的にデータ数を増やせますし(=過学習の防止という意味でもこれは有効)、既存の学習用データに回転(やりすぎは禁物!)や、サイズ変更して作成した水増し学習用データを加えて学習モデルを作れば、異なる角度やサイズの文字にも対応できる、より頑健なモデルにすることができます。

※ 過学習(Overfitting):学習用データが少なかったりすると、学習モデルがそのデータに最適化されすぎてしまい、汎化性能が低下してしまうことを言うそうです。つまり、見たことがあるデータしか、推論に成功しなくなる(見たことがないデータに対して非常に弱くなる)わけですね。

こうして作成した水増し学習用データをを元の約7000枚に追加し、画像をランダムに並び替えて、連番の名前を付け直し、約7800枚の「2」の画像データを作り、そのうち1/3のデータは余白「4」、1/3のデータは余白「5」、1/3のデータは余白「6」を設定(余白の取り方を変更してモデルの汎用性を高めるため)して再学習用の縦横28ピクセルの画像データに変換し、1、3、4、5の各学習用データと合わせて、カタカナ「アイウエオ」の学習モデルを再度構築し直しました。

実際は、再度ではなく、再々々々・・・度の「構築し直し」ですが。
夢は、きっと、叶えるために、あります。

基本的な考え方としては(間違っているかもしれませんが)、学習用データの余白分布が4~6ピクセルであれば、モデルはその範囲内の「平均的」な状態、すなわち中央値に近い値(つまり5ピクセル)に合わせた特徴抽出を学習する(=最も代表的な状態に合わせて内部の重みが調整される)と仮定して・・・

(推論用データの余白の設定を中央値にすると正解率が良いように経験的に感じたのです)

この仮定がもし正しければ、推論用の画像データはそのすべてを「検出した輪郭の周囲に余白5を指定して作成」することで、モデルは最も慣れている条件下で推論動作を行える=最も良い正解率を示すはずだと・・・

実は、この輪郭検出(=文字認識)後、その周囲にどの程度の余白を設定するかについて最初は適当に「8」とか指定していたのですが、モデルの汎用性を高めるためには、学習データの余白の設定は一律に同じ設定としない方が良いはずなので、ある時、ふとその1/3に余白「8」、1/3に余白「9」、1/3に余白「10」を設定して学習モデルを作成し、推論の成否を確認していたところ、推論用データの余白を「9」に設定した場合に正解率がよくなるように感じました(正確に統計をとったわけではありません)。それと最終的には、学習用データ・推論用データともに縦横28ピクセルの画像とすることから、中央に配置した文字が実質縦横20ピクセル程度の領域に入る余白「4・5・6」あたりが最も適当であろうと考えたわけです。MNISTの作りを見ても、この考えは正しいように思われました。

もちろん、学習用データの余白を3・4・5として、推論用データの余白を中央値の4とする設定も考えましたが、余白が3ピクセルではさすがに小さすぎるのではないかと思い直し・・・ つまり、ちょっとした輪郭抽出のズレでも、文字がフレームに近づきすぎて、文字の上下左右の位置のバラつきが大きくなり、モデルが位置変動に過敏になる可能性が大きいと考えました。

逆に余白が6ピクセルと大きい方が、余白を3ピクセルとした場合よりも、文字が中央に安定しやすく、多少のズレがあっても特徴が大きく変わらなくなるはずです(機械学習においては、機械が覚え込んだ特徴量に近い特徴量を示す推論対象が正解とされるわけですから、このことは非常に重要です)。解答欄画像から輪郭検出を行って推論用データを作成する際の余白の設定を様々に変えて試行している際に、わずか1ピクセル、余白の設定を変更しただけで、正解になったり、不正解になったりする事実(プログラムのテストを繰り返す中で、この現象に気づいた当初は本当に不思議に感じました)は、まさにこの推測が正しいことの証明ではないかと思われました。

最終的には、すべて縦横28ピクセルの画像データとするわけですから、このあたりの判断がコトの成否を分ける、言わば「運命の分岐点」であったと、今、ここまでの歩みを振り返って思います。

また、この各数字の画像が約7000枚ずつあるというのは、僕の制作環境においては学習モデルを作成可能な制限ギリギリの値であったようで、学習モデル作成にあたってはまずPCそのものを再起動し、他のアプリが一切動作していない(メモリが十分に空いている)状態を作ってから、学習モデルを作成するスクリプトを実行する必要がありました。

ちなみに僕のPC環境(仕様)は、以下の通りです。

【デバイスの仕様】
プロセッサ	11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz   3.00 GHz
実装 RAM	32.0 GB (31.7 GB 使用可能)
システムの種類	64 ビット オペレーティング システム、x64 ベース プロセッサ
ペンとタッチ	10 タッチ ポイントでのペンとタッチのサポート

【Windowsの仕様】
エディション	Windows 11 Pro
バージョン	24H2
インストール日	‎2024/‎10/‎05
OS ビルド	26100.4351
エクスペリエンス	Windows 機能エクスペリエンス パック 1000.26100.107.0

様々なアプリを使用した後や、Webブラウザを開いたままの状態で学習モデルを作成するスクリプトを実行すると、必ず「メモリが足りません!」というエラーメッセージが表示され、学習モデルの作成に失敗してしまうので、「再起動直後に実行する」という手を思いつく前は、「もはやこれまで」とせっかく作った学習データを減らそうかと思ったりもしました。

誰も教えてくれる人はいませんので、すべてが手探り状態で、後から考えれば実に様々な「それくらい最初から気がつけよ!」みたいな「プロから見れば当たり前のこと」に気づくまでに、試行錯誤を繰り返し、膨大な時間を費やしつつ、一歩一歩前進するしかありません。

昼間は仕事があるし・・・、夜はあたまの回転がトロくなるし・・・、なんや・かんやで、
だいたい日付が変わる頃に目を覚まし、あとは朝が来るまで、ちいさな灯りをともして・・・
僕の人生の中で、いちばん充実した「時」を過ごします・・・

自動採点を、あきらめない以上は・・・ その時々で、僕に出来る最善を尽くすのみ です。

何はともあれ、左に傾いた「2」を新しく学習したモデルが出来ました!

このモデルを用いて「1」の推論に再チャレンジした結果です。


やった! やった!!

なせばなる!!!

もちろん、余白の設定は「5」としてあります。

画像はプロトタイプのものです。

正解ラベル:「2」の場合です。

No,10の画像の「2」が正解となっていることもうれしいことです。
実は、No,11の画像は、検証用にわざと誤りのデータを他の画像から切り貼りして作成したものです。
つまり、正解ラベル「1」のNo,11の「2」と、上のNo,10の「2」は同じデータと思われます。


正解ラベル:「3」の場合です。


正解ラベル:「4」の場合です。


正解ラベル:「5」の場合です。


数字も正しく読めるようになりました☆


次は、記号の「 ○ と × 」です。

正解ラベル:「 ○ 」の場合です。


正解ラベル:「 × 」の場合です。



・・・・・・・


2025 年 6 月 15 日 午前4時
とうとう・・・
夢がかないました!


とても静か・・・

まだみんな
眠っています。

これも夢かもしれません。

夢なら、どうか・・・

覚めないでください。

2.自動採点機能の使い方

ダウンロードした zip ファイルを展開すれば、すぐにお試しいただけるよう、次に紹介する採点サンプルデータを同梱してあります。記事の説明を参照しながら、操作していただけますよう、お願い申し上げます。

この記事の冒頭にも書きましたが、プログラムの動作には「Microsoft Visual C ++ ランタイムライブラリ」のインストールが必要です。お使いのPCに「Microsoft Visual C ++ ランタイムライブラリ」が入っていない場合は、下記 Web サイトから「VisualCppRedist_AIO_x86_x64.exe」をダウンロードし、ダウンロードしたプログラムを管理者権限で実行し、動作に必要なライブラリをPCにインストールしてください。なお、インストール時には Windows のユーザーアカウント制御(UAC) が起動し、管理者用のID とパスワードの入力を求められます。インストールでは、exe の名称からわかるように 32 ビット版と 64 ビット版それぞれの VC++ランタイムライブラリがお使いの PC にセットアップされます。なお、インストール後は(僕のPC環境では)再起動なしで、そのまますぐに AC_Reader.exe を実行できました。

「VisualCppRedist_AIO_x86_x64.exe」の入手先:

https://www.majorgeeks.com/files/details/visual_c_redistributable_runtimes_aio_repack.html

2025年6月11日現在、バージョンは「0.91.0」でした。上記 Web サイトの Download (64-Bit EXE) というリンクをクリックすればインストールプログラムをダウンロードできます。

【採点の準備】

AC_Reader.exe をダブルクリックしてプログラムを起動したら、「採点作業」ボタンをクリックします。ここで「Windows によって PC が保護されました」と書かれた青い画面が表示された場合は、当 blog の過去記事に対応方法の詳細な説明を載せてありますので、そちらをご参照ください。

「採点作業」ボタンをクリックすると、次のメッセージが表示されます。


「はい」をクリックすると、既存の採点設定を選択できるようになります。


採点設定ファイルを選択するには、ComboBox の右側の ∨ マークをクリックします。すると候補の選択肢として採点サンプルファイルが1つだけ表示されますので、これをクリックして選びます。


案内メッセージが表示されます。


フォルダ選択」用のダイアログが表示されますので、解答用紙画像の入っている「フォルダを選択」してから OK をクリックしてください。

【重要】 選択するのは「フォルダ」であって、「ファイル」ではありません!


案内メッセージが表示されます。よく読んで OK をクリックしてください。

【採点設定ファイルとフォルダの関係】

最初に選んだ「採点設定ファイル」は、試験で使用した解答用紙の解答欄の座標他が登録されています。ですので、同じ解答用紙を使用して行った試験であれば、すべて同一の採点設定ファイルで採点作業を行うことができます。

通常、テストは「クラス単位」で実施されますが、採点設定ファイルはどのクラスに対しても共通で利用しますので、クラス名を入れない名称を付けて保存(例:R7_考査①_数学Ⅰ)するよう、ユーザーの皆さまにはご案内しています。

解答用紙の画像は、通常であれば「クラス名を付けたフォルダ(例:R7_考査①_数学Ⅰ_1A)」に保存するのが一般的であると思います。

ですので、このプログラムの実際の運用に当たっては、「採点設定ファイルにはクラス名を入れず、解答用紙の画像を保存するフォルダにはクラス名を含めた名前を付けてください。」とユーザーの皆さまへご案内しております。

【採点方法】

自動採点は、次の GUI で行います(僕は「フローティングパネル」と呼んでいます)。いろいろ考えてデザインしましたが、使い勝手がよくないと感じられる方もいらっしゃるかもしれません。そうだったら、ほんとに、ごめんなさい。

上部のタイトルバーに相当する部分を左クリックして、
そのまま(左ボタンを押したまま)ドラッグすると、
フローティングパネルを任意の位置へ移動できます。
(閉じるボタンは無効化してあります)


まず、現在、採点しようとしている設問への配点を設定します。


以下、手動採点時の採点方法の説明です。

手動採点時には、このまま、配点設定欄の下にある入力ボタンをクリックすると、配点設定欄が「0」であれば、現在表示されているすべての解答欄に不正解の「×」が、配点設定欄が「1以上」であれば、現在表示されているすべての解答欄に正解の「○」が(設定によっては配点の数字も)自動で入力されます。

これは、つまり、手動採点時には、初めに解答欄全体の出来栄えを見て、全体的によく出来ているような場合は一括して正解とし、不正解の解答欄だけを手動で採点、逆に全体的に出来がよくない場合には、一括して不正解とし、正解の解答欄だけを手動で採点した方が、効率よく採点できると考えて、このような仕様としました。

もちろん、自動採点時には、この入力ボタンをクリックする必要はありません。

また、配点を設定する ComboBox の右隣りの CheckBox「□する」にチェックを入れると、手動採点時に入力ボタンをクリックして、一括採点操作が行われる前に確認メッセージが表示されるようになります(誤入力を防ぎたいという、ユーザーからの要望で追加した機能です)。

【ここから自動採点の実行方法の説明です】

配点を入力後、自動採点を行う場合は、「□自動採点」にチェックを入れます。
次に、その下にある ComboBox からその設問の正解を選び、実行ボタンをクリックします。

正解として指定できるのは・・・

・カタカナの「ア・イ・ウ・エ・オ」のいずれか1文字、
・記号の「○・×」のどちらか1つ、
・数字の「1・2・3・4・5」のいずれか1つです。

これ以外のカタカナ(例えば「カ」)、記号(例えば「△」)、数字(例えば「6以上の数字」)は指定できません(決まりとして指定できないだけで、正解ラベルとしてComboBoxの入力欄に入力することはできます・・・が、正しく採点することは絶対に不可能です)。ただ、数字のゼロは、たぶん記号の「○」で代用が可能かと思われます・・・ ので、数字については、もしかしたら「0・1・2・3・4・5」の6種類が採点可能かも?しれません(試していませんが)。

また、正解ラベルに指定する文字・記号・数字は、直接入力せず、ComboBox の選択肢から選択してください。記号の「○:まる」に誤って漢数字の「ゼロ:〇」を指定しないようご注意願います。漢数字のゼロは「まる」の変換でも IME の変換候補の選択肢に表示されるので十分注意してください。

「チェック」→「正解ラベル選択」→「実行」です。


採点が完了すると、解答欄の画像の左上に、採点記号(自動採点を利用した場合は、○ or × のいずれか)と、先に設定した配点が赤く表示されます(表示位置は任意の位置に変更できます。変更方法は上で紹介しました当 blog の過去記事をご参照ください)。

自動採点実行直後の状態

お願い
ここで、全ての解答欄について、機械の採点結果を目視で必ず確認してください。

※ このプログラムは、添付した学習モデルの性能が及ぶ範囲で「正解・不正解」のいずれかを判定する自動採点を実行しますが、自動採点結果について、それが常に 100 %「正しい」ことを保証するものではありません。自動採点を行った結果につきましては、必ず、ご自身の責任で、直接、目視によって、その成否をご確認いただけますよう、お願い申し上げます。この使用条件に完全に同意し、かつ確実に目視による確認作業を実行していただける方のみ、このプログラムをお使いいただけますことを申し添えます。このプログラムに搭載した手動及び自動の採点機能を利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

【修正が必要な場合】

もし、修正が必要な場合は、修正対象の解答欄の画像をまずクリックします。

・正解に修正する場合は、配点に相当する数字キー(その設問の配点が「2」なら「2」のキー)を押下げします。

・不正解に修正する場合は、「B」キーを押下げします。ちなみに「B」は「 ×:Batsu 」の頭文字で、右手でマウス・左手で手動採点する際に「B」キーは押しやすい位置にあり、また、機能を覚えやすいんじゃないかと考え、「B」を不正解の入力キーとしました。

【採点結果の保存方法】

採点結果を保存(=書込み)しないと、次の解答欄を表示することはできません。実行の左隣にある「書込」ボタンをクリックしてください。採点結果が保存されます。

3.推論用画像データの確認

プログラム設計時の動作検証用に作成した機能ですが、解答用紙画像から切り出した解答欄画像と、その解答欄画像から切り出した推論用画像データの状態を確認することが出来ます。

【解答欄画像の確認方法】

まず、次のように、正解ラベルが「空欄」の状態で確認作業を実行した場合、解答用紙画像から切り出した解答欄画像を確認することが出来ます。

正解ラベルは「空欄」のままにしておきます。


正解ラベルが「空欄」のままであることを確認した後、「設定」→「推論用画像を確認する」の順にクリックしてください(元々、開発時に推論用画像を確認するために設けた機能なので、ボタンの名称が「解答欄・・・」ではありません)。

画像はプロトタイプのものです。


【重要】 設定画面表示中は、Form の「閉じる」ボタンは無効化されます。

解答欄画像が表示されます。


解答用紙から切り出した解答欄画像のクリーニングは、採点作業補助用の GUI (フローティングパネル)の CheckBox 「□自動採点する」をチェックして、さらに正解ラベルが空欄ではない状態で、実行ボタンをクリックすると行われる(ように設定してある)ので、クリーニング前の状態を確認したい場合は、自動採点を実行する前の段階、すなわち、「◀」もしくは「▶」ボタンをクリックした直後の、まだ「□自動採点する」をチェックせず、正解ラベルも指定していない状態で、「設定」ボタンをクリックして、「推論用画像を確認する」をクリックすれば(クリーニング前の解答欄画像を)表示できます。

リリース版では、上のプロトタイプの状態にさらに画像のクリーニング機能を追加、パラメータが増えたため、ボタンのキャプションは単に「推論用画像」としています。

採点エンジンは2系統あります。
Version1 を選択した場合は、各パラメータを調整できます。
(デフォルト設定は、パラメータを調整済みの Version2 としてあります)

プログラムは、「実行」ボタンをクリックすると、まず、解答用紙から切り出した解答欄画像のクリーニングを行って、それから自動採点を行います。初回のみならず、2度目、3度目の見直し採点時であっても、プログラムは「修正等を一切加えていない無加工の解答用紙画像」から解答欄を切り抜いて解答欄画像として表示しているので、汚れのある解答欄が毎回表示されます。クリーニングが行われるのは、実行ボタンをクリックした後であることにご留意ください。

【点状汚れの除去の例】

画像の左下隅に点状の汚れがあります。


クリーニング後の画像は・・・

よーく見ると、微かにうすいシミが残っていますが・・・まぁ、消えたと言えるんじゃないかと。
(ここは後日、さらに改良してより白くなるように修正しました)

【線状汚れの除去の例】

クリーニング前の画像の例(1番目と12番目の画像左端に線状の汚れがあります)


クリーニング後の画像は・・・

線状の汚れは消えました!

【推論用画像の確認方法】

自動採点を実行すれば、推論用に解答欄画像から切り出した、縦横28ピクセルの推論用画像を確認できます。自動採点時、実際に機械が見ているのは、この推論用画像になります。

正解ラベルが指定されている場合は、推論用画像を確認できます。


上の図のような状態で、「実行」ボタンをクリックした後で、「設定」→「推論用画像」の順にクリックします。

推論用画像が表示されます。


縦横28ピクセルの、この小さな画像を思った通りに切り出せるようになるまで、いったいどれくらいの試行錯誤を繰り返したか、今はもうそのすべてを思い出せませんが、自分の中に「あきらめる」という選択肢だけはなかったように思います。

これまでの経験から、ただひとつだけ言えることは、機械学習の成否はこの機械が見る(機械に見せる)画像にあるということです。

学習用データとまったく同じ手法で作成した推論用画像を自分では「ブレない画像」と呼んでいますが、画像中の汚れ・シミ等も含めて輪郭検出した部分の面積を計算し、その大きな部分を組み合わせた範囲を文字として切り抜き、中心位置を計算し、最適な余白を設け、汚れ・シミを除去し、白い部分はより白く、逆に薄い灰色は黒く(濃く)する等、文字の特徴量抽出を阻害する要素をできるだけ取り除いた、わずか縦横28ピクセルの、このちいさな文字。その「作り方」として、僕のとった方法が正解であったかどうかの答えを AC_Reader が出してくれると信じています。

もりろん、手書き文字にひとつとして同じ文字はありませんから、そのような意味で「正しいア」は存在しません。ただ、これまでの経緯から、特徴量抽出で機械が学んだ「ア」こそ、もしかしたら「正しいア」に最も近い「ア」なのではないかと思うようになりました。

ひとことで言えば、「正解がないのに、正解を探す旅」それが今、僕が思う機械学習のイメージです。

4.プログラムのダウンロード

自動採点機能みたいなモノを搭載した手書き答案の採点補助プログラム AC_Reader.exe 及びその関連プログラム一式を同梱した AC_Reader_v3.0_AutoGrading.zip は、次のリンクからダウンロードできます。なお、ダウンロードとご使用にあたっては、免責事項及び使用条件への同意が必要です。免責事項及び使用条件の詳細は付属の Readme.txt をご覧ください。

5.お願いとお断り

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

追記_返却用答案の印刷方法について

元々、この AC_Reader には簡易的な合計点の計算と返却用答案の印刷機能があったのですが、高等学校現場における観点別評価の導入に伴い、返却用答案の印刷プログラムは、マークシートリーダーと共用の別プログラム(ReportCard_2024.exe)としました。

AC_Reader.exe から ReportCard_2024.exe を呼び出して実行できます。ReportCard_2024.exe の操作方法は、当ブログの過去記事をご参照ください。


こちらの過去記事にも ReportCard_2024.exe の操作方法の解説があります。上の記事と合わせてご参照ください。

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


ほんとうは、今回のお話のタイトルは・・・

手書き答案の「デジタル採点補助プログラム」のつもりで作った僕のAC_Reader に自動採点機能も搭載しました!

・・・にしたかったのですが、すみません。その前に、自動採点を行うための準備ついて、どこのサイトにもあまり書いてないことを、書いておきたいと思います。

これから書くことは、もしかしたら僕が知らなかっただけで、機械学習に携わる方であれば注意・留意事項以前の「常識」と言っていいようなことなのかもしれません。

それでも、万一にでも、僕の経験が、初めて機械学習や自動採点に挑戦される方の参考になれば、それこそ、何よりの幸いです。

追記

機械学習のライブラリは何にするか・・・とか、溢れんばかりに、いや、溢れかえるほどに情報があることではなく、僕は、それ以前の物語(準備作業)の重要性に気づいたのです。思ったような結果が出ないのは、ライブラリが悪いのではなく、学習用データや推論用データの作り方に問題があったのです。

ある規格に揃えられた、ブレないデータで学習し、学習時と同じ規格で生成された、ブレないデータで推論(判定)する。これがさんざんまわり道をしてたどり着いた、僕なりの結論です。機械学習の最重要ポイントは、データの作成にありました。

【もくじ】

1.学習&推論データについて
2.解答欄の切り出し
3.解答欄からの解答の切り出し
4.学習用データを作る
5.学習モデルを作る
6.まとめ
7.お願いとお断り

1.学習&推論データについて

機械学習を行うためには、機械に学習させるデータが必要なことは言うまでもありません。数字ならMNIST、日本語のカタカナであれば ETL といったところでしょうか。

2年前、初めて機械学習にチャレンジしたとき、上の2つのデータベースを知り、当時は keras とニューラルネットワークを使ってカタカナ「アイウエオ」の自動採点に挑戦・・・

それなりに時間と、手間暇をかけて自分なりに頑張ったのですが、どうしても夢見たような結果が得られず、最終的には・・・ 自作のデジタル採点プログラムへの搭載を断念。

そのいちばんの原因は、(今思えば)学習モデル作成以前に、「高品質な学習データを準備できなかった」ことにありました。

例えば、学習データとする文字・数字・記号を縦横 28 ピクセルの画像として用意するとした場合、画僧中の文字・数字・記号の大きさ、位置、濃さ、その他、画像中のシミや汚れ、等々と言った実に様々な要素の影響を考慮し、必要な場合は修正(補正)を施して・・・、

学習モデル作成用に準備した学習用画像の「それ」と

実際の採点に利用する解答用紙の解答欄から切り出した推論用画像の「それ」が

完全に一致するように「学習用」&「推論用」画像を準備しなければなりません。

場合によっては、推論対象ごとに処理(修正・補正)を変更する必要すら生じます。例えば「1」や「イ」など、その形状が比較的単純な数字・文字は、画像を二値化して処理した方が認識率が高まるのではないかと実験して感じました(あくまでも、僕自身の実験結果からの判断です。ご注意ください。ただ、僕自身は、この目で見た実験の結果を信じて、推論対象とする数字や文字ごとに処理を分けて実装しています)。

2年前の僕は、学習用画像を作成する段階で、解答用紙の『解答欄の切り出し』にはなんとか成功したものの、解答欄の中の『解答そのものの切り出し』に失敗(例えば、同じ「ア」でも、「つ」と「ノ」の組み合わせのように見える「ア」の場合、機械は「ア」ではなく、「つ」と「ノ」のように別々に輪郭検出)してしまい、高品質な学習用画像が作れませんでした。もちろん、同じ理由から、思うような推論用画像も、生成できるわけがなく・・・

様々に試行を繰り返しましたが、結果としては、自作ソフトへの自動採点機能の搭載を断念せざるを得ませんでした。ただ、自分がとった方法では『ダメ』だという事実と、無加工状態の大量の手書きのカタカナ文字「アイウエオ」、数字の「0~9」、記号の「 〇 と × 」の画像データが残りました。今回の再チャレンジで、これらのデータが役に立ったことは言うまでもありません。もちろん、『ダメだった』という貴重な経験も、今回はその方向に進んではいけないという大切な良い指標となりました。

まとめると、良い学習モデルを作成するためには、学習モデルを作成するために使用する学習用画像そのものの品質を、高品質化・・・ と言うか、学習用画像の作成方法と、推論用画像の作成方法の差異をなくし、縦横 28 ピクセルの画像とする過程で、数字・文字・記号の大きさを揃え、画像中の位置を中心化し、シミや汚れの除去等々、徹底した修正(補正)を行って、機械が学習しやすく、かつ、判定もより確実に行えるよう、推論用の画像データも学習用データと同じ処理を行って作成したものにする等、ヒトの側で、学びやすい環境と推論しやすい環境を整えてあげることが、ライブラリ云々以前に、他のどんな要素よりも重要で大切なことなんだということが(僕がそう思うだけで、これは誤った考えかもしれませんが)自分なりに納得できた、機械学習で使用する学習&推論用データ作成に関する最終的な結論です。

以下、僕自身が行った画像の切り出しと修正(補正)方法の一部を紹介します。

2.解答欄の切り出し

2年前は、64 ビット環境で作業したのですが、今回は敢えて 32 ビット環境での機械学習にチャレンジすることにしました。理由は、ただひとつ。自分のアプリケーションがいつも利用している組み込み用の Python 環境である Embeddable Python が 32 ビットバージョンであるためです。

利用するライブラリも、2年前の keras ではなく、scikit-learn に変更しました。2年前は、見様見真似で作ったニューラルネットワークを用いましたが、今回は特徴量を抽出する手法(HOG + LBP)を用いて学習モデルを作成し、推論に利用しました。

世の中の流れには、完全に逆行しているような気がしますが、『正しく自動採点できた!』という結果が出せれば、方法は何でも良いと考え、ニューラルネットワークのことは忘れることにしました。

『機械学習』と言えば、即、ニューラルネットワークだと思い込んでいた・・・2年前の僕に、今は・・・、「そんなに短絡的に、思い込まなくても、よかったんじゃないか・・・」って言ってあげたい気もします。

それより、学習用データや、推論用データを、しっかり作ることの方が、大切だよ・・・って。

データが正しければ、ライブラリは間違えない。
データに誤りがあれば、ライブラリも間違える。

ライブラリの性能を、最高に引き出せるデータを作ることが、

機械学習では、きっと・・・

いちばん、大切な、こと・・・ なんだよ って。

それが、今回のチャレンジを終えて、感じた・・・ 僕自身の偽りのない、正直な、思いです。

プログラムに自動採点機能を実装するためには、文字を認識し、推論(判定)する処理が必要です。そのため、最初に行わなければならないのが解答用紙画像から解答欄矩形を切り出す処理です。これには次のようなスクリプトを使用しました。

# 解答用紙から解答欄矩形を切り出すスクリプト(AnswerColumnCutter.py)

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

# 入出力フォルダ
input_folder = r'.\MyData'  # 解答用紙画像のあるフォルダ
output_folder = r'.\ACData'  # 切り出した解答欄の保存先
os.makedirs(output_folder, exist_ok=True)

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

# 解答欄サイズの閾値(調整可能)
min_width = 100
min_height = 50
max_width = 800
max_height = 400

# 保存ファイルの連番用カウンタ
save_index = 1

# 処理ループ
for image_path in image_files:
    filename = os.path.basename(image_path)

    # 画像読み込み(日本語ファイル名に対応)
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        print(f'読み込み失敗: {filename}')
        continue

    # グレースケール変換と二値化
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 輪郭検出(内枠も含める)
    contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area < 1000:
            continue

        approx = cv2.approxPolyDP(cnt, 0.02 * cv2.arcLength(cnt, True), True)

        if len(approx) == 4:
            x, y, w, h = cv2.boundingRect(approx)

            if min_width <= w <= max_width and min_height <= h <= max_height:
                roi = image[y:y+h, x:x+w]

                save_name = f'answer_{save_index:04d}.png'
                save_path = os.path.join(output_folder, save_name)
                cv2.imencode('.png', roi)[1].tofile(save_path)

                save_index += 1

print(f'Saving complete! {save_index - 1} items saved.')

実行結果は、次の通りです。数字・文字(記号)は、この記事用にすべて自分で書きました。

一部の「〇と×」のデータは不要ですので削除する必要があります。


これでOKかというと、実はOKではありません。解答用紙の画像から切り出した解答欄の画像1枚1枚をよく見ると・・・

上の赤枠の中に、うっすらと解答欄の「枠線(罫線)」の一部が見える


文字の他に、解答欄の矩形の一部が見えます。最終的には輪郭検出で文字の部分のみを見つけて、文字のみを切り出すので影響はないようにも思いますが、より確実に文字を切り出すために不安要素はすべて準備段階で取り除いておくことにしました。

上のスクリプトに、枠線(罫線)を除去する機能を追加します。

# 解答欄矩形の枠線を消す処理を追加したスクリプト(AnswerColumnCutter2.py)

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

# 入出力フォルダ
input_folder = r'.\MyData'  # 解答用紙画像のあるフォルダ
output_folder = r'.\ACData'  # 切り出した解答欄の保存先
os.makedirs(output_folder, exist_ok=True)

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

# 解答欄サイズの閾値(調整可能)
min_width = 100
min_height = 50
max_width = 800
max_height = 400

# ROIの枠線除去用パディング(上下左右のピクセル数)※状況によっては、個別に指定することも可とした
Pad = 10  # 画像の状態に応じて適宜修正する
padding_top = Pad
padding_bottom = Pad
padding_left = Pad
padding_right = Pad

# 保存ファイルの連番用カウンタ
save_index = 1

# 処理ループ
for image_path in image_files:
    filename = os.path.basename(image_path)

    # 画像読み込み(日本語ファイル名対応)
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        print(f'読み込み失敗: {filename}')
        continue

    # グレースケール変換と二値化
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 輪郭検出(内枠も含める)
    contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area < 1000:
            continue

        approx = cv2.approxPolyDP(cnt, 0.02 * cv2.arcLength(cnt, True), True)

        if len(approx) == 4:
            x, y, w, h = cv2.boundingRect(approx)

            if min_width <= w <= max_width and min_height <= h <= max_height:
                roi = image[y:y+h, x:x+w].copy()

                # 枠線を削除(上下左右 padding ピクセルを白で塗りつぶす)
                roi[:padding_top, :] = 255  # 上
                roi[-padding_bottom:, :] = 255  # 下
                roi[:, :padding_left] = 255  # 左
                roi[:, -padding_right:] = 255  # 右

                save_name = f'answer_{save_index:04d}.png'
                save_path = os.path.join(output_folder, save_name)
                cv2.imencode('.png', roi)[1].tofile(save_path)

                save_index += 1

print(f'Saving complete! {save_index - 1} items saved.')

結果は、次の通りです。

周囲が真っ白になりました!


では、これで OK かというと、まだ問題があります。問題の1つが画像中の黒や灰色の汚れです。


これらも出来る限り、除去できるよう解答欄の切り出しスクリプトを改良します。

'''
解答欄矩形の枠線を消す処理を追加したスクリプト(AnswerColumnCutter2.py)に
黒点も削除する処理を追加したAnswerColumnCutter3.py
'''

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

# 入出力フォルダ
input_folder = r'.\MyData'  # 解答用紙画像のあるフォルダ
output_folder = r'.\ACData'  # 切り出した解答欄の保存先
os.makedirs(output_folder, exist_ok=True)

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

# 解答欄サイズの閾値(調整可能)
min_width = 100
min_height = 50
max_width = 800
max_height = 400

# ROIの枠線除去用パディング(上下左右のピクセル数)
Pad = 5
padding_top = Pad
padding_bottom = Pad
padding_left = Pad
padding_right = Pad

# 保存ファイルの連番用カウンタ
save_index = 1

# 処理ループ
for image_path in image_files:
    filename = os.path.basename(image_path)

    # 画像読み込み(日本語ファイル名対応)
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        print(f'読み込み失敗: {filename}')
        continue

    # グレースケール変換と二値化(binaryにはblurされた白黒反転画像が入る)
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blur = cv2.GaussianBlur(gray, (5, 5), 0)
    _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 輪郭検出(内枠も含める)
    contours, _ = cv2.findContours(binary, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)

    for cnt in contours:
        area = cv2.contourArea(cnt)
        if area < 1000:
            continue

        approx = cv2.approxPolyDP(cnt, 0.02 * cv2.arcLength(cnt, True), True)

        if len(approx) == 4:
            x, y, w, h = cv2.boundingRect(approx)

            if min_width <= w <= max_width and min_height <= h <= max_height:
                # imageは元のカラー画像(輪郭検出に使用したbinaryではないことに注意する!)
                roi = image[y:y+h, x:x+w].copy()

                # 枠線を削除(上下左右 padding ピクセルを白で塗りつぶす)
                roi[:padding_top, :] = 255  # 上
                roi[-padding_bottom:, :] = 255  # 下
                roi[:, :padding_left] = 255  # 左
                roi[:, -padding_right:] = 255  # 右

                # --- シミやノイズを除去する処理を追加 ---
                gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

                # 小さな黒点や灰色点を除去(モルフォロジー開演算)
                kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
                opened = cv2.morphologyEx(gray_roi, cv2.MORPH_OPEN, kernel, iterations=1)

                # 小さな輪郭(ノイズ)を除去
                cleaned = opened.copy()
                contours_noise, _ = cv2.findContours(255 - opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
                for c in contours_noise:
                    if cv2.contourArea(c) < 150:  # 小さな汚れを消す
                        cv2.drawContours(cleaned, [c], -1, 255, -1)

                # グレースケール→カラーに戻す
                cleaned_color = cv2.cvtColor(cleaned, cv2.COLOR_GRAY2BGR)
                roi = cleaned_color

                # 保存
                save_name = f'answer_{save_index:04d}.png'
                save_path = os.path.join(output_folder, save_name)
                cv2.imencode('.png', roi)[1].tofile(save_path)

                save_index += 1

print(f'Saving complete! {save_index - 1} items saved.')

結果は、次の通り。

汚れがきれいに消えました!


しかし、まだ問題が残っています。それは・・・

画像の左側に灰色の直線のようなものが入っている


この灰色の直線のようなものが入る理由がわからないのですが、現実問題として、僕が利用している複合機でスキャンしたJpeg画像には時折り、このような直線が入ってしまいます(もっと黒い線になることもあります)。理由はともあれ、これを除去できるよう、新しくスクリプトを作成しました。解答用紙から切り出して保存した解答欄画像に対して処理を行っていることにご注意ください。

'''
縦線は画像の高さに匹敵する長さ、
横線は画像の幅に匹敵する長さを持つ直線のみを除去するスクリプト。
image.shape を使って幅 (width) と高さ (height) を取得。
縦線: 傾き ≒ 垂直(3度以内)かつ 長さ ≥ 高さの 80%。
横線: 傾き ≒ 水平(3度以内)かつ 長さ ≥ 幅の 80%。
'''

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

# 処理対象フォルダ
folder = r'.\ACData'  # 解答欄画像として保存したデータを修正しています
image_extensions = ['*.png', '*.jpg', '*.jpeg']
image_paths = []
for ext in image_extensions:
    image_paths.extend(glob(os.path.join(folder, ext)))

for image_path in image_paths:
    # 日本語ファイル名対応で画像読み込み
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        print(f"読み込み失敗: {image_path}")
        continue

    height, width = image.shape[:2]
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    # エッジ検出(低い閾値で薄い線も対象)
    edges = cv2.Canny(gray, threshold1=20, threshold2=80, apertureSize=3)

    # HoughLinesPで直線検出
    lines = cv2.HoughLinesP(
        edges,
        rho=1,
        theta=np.pi / 180,
        threshold=50,
        minLineLength=30,
        maxLineGap=5
    )

    # 線を描画するマスク
    mask = np.zeros_like(gray)

    if lines is not None:
        for line in lines:
            x1, y1, x2, y2 = line[0]
            dx = x2 - x1
            dy = y2 - y1
            length = np.sqrt(dx ** 2 + dy ** 2)

            # 傾きが垂直に近く、高さに匹敵する長さを持つ線
            if (abs(dx) < 1e-5 or abs(dy / dx) > 20) and length >= height * 0.8:
                cv2.line(mask, (x1, y1), (x2, y2), 255, thickness=2)

            # 傾きが水平に近く、幅に匹敵する長さを持つ線
            elif (abs(dy) < 1e-5 or abs(dx / dy) > 20) and length >= width * 0.8:
                cv2.line(mask, (x1, y1), (x2, y2), 255, thickness=2)

    # マスクされた領域を修復(inpainting)
    if np.count_nonzero(mask) > 0:
        inpainted = cv2.inpaint(image, mask, inpaintRadius=3, flags=cv2.INPAINT_TELEA)
    else:
        inpainted = image  # 線が見つからなければそのまま

    # 上書き保存(日本語ファイル名対応)
    cv2.imencode('.png', inpainted)[1].tofile(image_path)

    print(f'修正完了: {os.path.basename(image_path)}')

print("全ファイルの処理が完了しました。")

結果は、次の通り。

左側にあった灰色の直線が消えました!


これでようやく安心して使える解答欄の切り出し画像が準備できました!

3.解答欄からの解答の切り出し

次は、解答の切り出しです。2年前はここで失敗しました。今回、あらためて失敗の原因を考えてみると、2年前も輪郭検出までは成功したのですが、輪郭検出できた場合に、『その後の処理をどう行うか?』という部分で(2年前は)工夫が足りなかったことに気づきました。

それはどういうことか、説明します。
まず、輪郭検出です。わかりやすさのために、検出した部分を赤枠で囲って示します。


文字全体が一筆書きのように描かれていれば正しく検出できるのですが、文字を構成する部品が独立して描かれている場合には、文字全体を正しく検出できていません。

今回は、『輪郭検出できた部分を組み合わせて出来る範囲の周囲を文字と見なして切り取る』という方法を用いてみました。次の画像にその結果を示します。


左から順に拡大して見てみます。


拡大すると、文字を構成する部品が完全に繋がっているわけではないようです。が、輪郭検出自体には成功しています。輪郭検出に使用した OpenCV は本当に優秀なライブラリです。


こちらの「ア」は、3つの輪郭の範囲を合わせて文字として認識。切り出しに成功しました!


こちらの「イ」は、2つの輪郭の範囲を合わせて文字として認識。切り出しに成功しました!

では、これで本当に OK かというと、コトはそう簡単ではありませんでした。

次のような、黒点が残ってしまった画像に対し、この切り抜き処理を実行すると・・・


次のように、左隅の黒点部分まで、文字を構成する部品の一部と見なし、(ヒトから見れば)誤った範囲を文字として切り出してしまいます。

機械的には、極めて正確に、ヒトの命令に忠実に、正しい処理を行っているわけですが・・・


この問題に対しては、『検出した輪郭の中から「面積の大きな輪郭」(最大輪郭の面積の10%以上のもの)をすべて組み合わせた領域を文字領域とみなし、その周囲に上下左右10ピクセルの白い余白を付けて切り抜く』方法で対応しました。次がそのスクリプトです。

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

# 入出力フォルダのパス(必要に応じて変更)
input_folder = r'.\MyInputFolder'     # ←処理対象フォルダ
output_folder = r'.\Crop04_Pic'       # ←保存先フォルダ
os.makedirs(output_folder, exist_ok=True)

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

# 処理ループ
for image_path in image_files:
    filename = os.path.basename(image_path)

    # 日本語ファイル名対応の読み込み
    image = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        print(f'読み込めません: {filename}')
        continue

    # グレースケール & 二値化
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 縦線除去処理(細い直線ノイズを消す)
    vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 30))  # 縦方向に長いカーネル
    vertical_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel, iterations=1)
    binary_cleaned = cv2.subtract(binary, vertical_lines)

    # 輪郭検出(外側のみ)
    contours, _ = cv2.findContours(binary_cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    if not contours:
        print(f'輪郭なし: {filename}')
        continue

    # 最大輪郭の面積を基準に、大きな輪郭(最大輪郭の10%以上)を抽出
    max_area = max([cv2.contourArea(c) for c in contours])
    area_threshold = 0.1 * max_area
    large_contours = [c for c in contours if cv2.contourArea(c) >= area_threshold]
    if not large_contours:
        print(f'大きな輪郭なし: {filename}')
        continue

    # 大きな輪郭群の外接矩形の結合領域を求める
    x_vals = []
    y_vals = []
    x2_vals = []
    y2_vals = []
    for cnt in large_contours:
        x, y, w, h = cv2.boundingRect(cnt)
        x_vals.append(x)
        y_vals.append(y)
        x2_vals.append(x + w)
        y2_vals.append(y + h)
    combined_x = min(x_vals)
    combined_y = min(y_vals)
    combined_x2 = max(x2_vals)
    combined_y2 = max(y2_vals)

    # 余白を加える(画像範囲内に収める)
    pad = 10
    x1 = max(combined_x - pad, 0)
    y1 = max(combined_y - pad, 0)
    x2 = min(combined_x2 + pad, image.shape[1])
    y2 = min(combined_y2 + pad, image.shape[0])
    cropped = image[y1:y2, x1:x2]

    # 保存(PNG形式、元のファイル名と同じ名前)
    save_path = os.path.join(output_folder, os.path.splitext(filename)[0] + '.png')
    cv2.imencode('.png', cropped)[1].tofile(save_path)

print(f'Saving complete!')

次のように、構成部品が離れている「ア」であっても(思った通りに)切り出すことに成功しました!


このようにして切り出した画像から、次に機械学習による学習モデルを作るための学習用データを準備します。今回は、scikit-learn の HOG特徴量抽出を利用するので、解答欄から切り出した手書き数字・文字・記号の画像を、手書き数字や単純な記号認識に適しているとされ、MNISTデータセット(手書き数字認識の標準データセット)で採用されているサイズである 28 × 28 ピクセルの画像に変換します。

次に、その変換方法について説明します。

4.学習用データを作る

機械学習の学習用データの作成方法として、僕が行ったことが正しいかどうかは、この記事をお読みになった方ご自身でご判断ください。僕自身は、機械学習を理論的な背景を含め、基礎からきちんと学んだことはありませんし、今回利用した HOG( Histogram of Oriented Gradients )+ LBP(Local Binary Patterns )という特徴量抽出手法についてもその詳細な部分まで理解しているわけではないからです。そのような点を御理解の上、記事をお読みいただけましら幸いです。

学習用データは、予め、「ア」なら「ア」だけを、正解ラベル名を付けたフォルダに分類しておきます。

実際には、ア~オの各文字につき、2600枚程度の画像を用意しました!


これを処理して、次に示すような 28 × 28 ピクセルの画像を作成します。


この 28 × 28 ピクセルの画像を作成する過程で、必要に応じて、補正処理をかけ、機械学習を行うために必要十分と思われる画像となるよう準備します。ここで言う必要十分とは、機械に見せる画像内の推論対象の「大きさ・位置・傾き・濃さ」等をヒト基準で一定の範囲に収まるように、予め個々の画像を学習前・推論前に調整し、学習時も推論時も同じ処理の過程を経て作成された・・・言わば「ブレていない」画像(データ)を機械が見れる = 機械は余計な気遣いなどできないので、同じ条件下で作成された画像を見て、機械は「その特徴量抽出のみに専念できるようにする」という意味です。

繰り返しになりますが、学習用画像を作成する時だけでなく、推論用画像を作成する際も、学習用画像を作成する際に行ったのと同じ処理をそっくりそのまま行って、機械が常に同じ(安定した)条件下で推論(手書き文字の認識作業)を実行できるようにするという部分も非常に重要だと考えます。

処理に使用したライブラリの一覧です。

import cv2
import numpy as np
import os
from glob import glob
import re
import joblib
from skimage.feature import hog, local_binary_pattern

文字を傾きを均一化し、分類器がより正確な特徴を学習できるようにするために次の関数を用意。

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)

学習用画像データの読み込み処理部分は割愛します。

次が学習用画像の処理ループ部分です。任意に指定した学習用データを保存したフォルダ内の全画像について処理を以下の通り実行します。補正処理の実行内容は、各々のコメントをご参照ください。

index = 1
light_text_threshold = 215  # 文字の視認性の向上(薄いと判断する閾値)
pad = 10                     # 周囲に設定する余白
clip_limit = 0.3            # コントラストの過剰な増加を防ぐための制限値。ごく弱く設定。
tile_grid_size = 2          # 画像を分割するグリッドのサイズ。ごく小さめに設定

# 学習用データの数だけループする
for image_path in image_files:
    image = imread_utf8(image_path)
    if image is None:
        continue
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    '''
    小さすぎる or 明るすぎる成分は除外して、画像の連結成分を解析し、有効な成分のみを抽出、
    適切な境界を決めて、その範囲を文字として切り出す処理。
    '''
    num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(binary, connectivity=8)
    min_area = 50
    brightness_threshold = 200

    valid_components = []
    for i in range(1, num_labels):
        x, y, w, h, area = stats[i]
        if area < min_area:
            roi = gray[y:y+h, x:x+w]
            mean_val = cv2.mean(roi)[0]
            if mean_val > brightness_threshold:
                continue
        valid_components.append((x, y, w, h))

    if not valid_components:
        cropped = np.full((28, 28, 3), 0, dtype=np.uint8)
    else:
        x_vals = [x for x, y, w, h in valid_components]
        y_vals = [y for x, y, w, h in valid_components]
        x2_vals = [x + w for x, y, w, h in valid_components]
        y2_vals = [y + h for x, y, w, h in valid_components]
        combined_x = min(x_vals)
        combined_y = min(y_vals)
        combined_x2 = max(x2_vals)
        combined_y2 = max(y2_vals)

        x1 = max(combined_x - pad, 0)
        y1 = max(combined_y - pad, 0)
        x2 = min(combined_x2 + pad, image.shape[1])
        y2 = min(combined_y2 + pad, image.shape[0])
        cropped = image[y1:y2, x1:x2]

    '''
    明るい文字のコントラストを調整し、認識しやすくする処理。白飛びを防ぎ、画像の視認性を改善する。
    '''
    trimmed_gray = cv2.cvtColor(cropped, cv2.COLOR_BGR2GRAY)
    mask = (trimmed_gray >= light_text_threshold).astype(np.uint8) * 255
    adjusted = trimmed_gray.copy()
    adjusted[mask == 255] = np.clip(adjusted[mask == 255] - 20, 0, 255)

    # 輪郭検出
    contours, _ = cv2.findContours(adjusted, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    if contours:
        # CLAHEを適用
        clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile_grid_size, tile_grid_size))
        trimmed_gray = clahe.apply(trimmed_gray)
        # 調整した結果、ここではぼかし無しと同等にしておくことにした。必要であればより強く設定。
        trimmed_blur = cv2.GaussianBlur(trimmed_gray, (1, 1), 0)
        # 単純な形であれば二値化して処理する
        if label in ["イ", "1"]:
            _, trimmed_thresh = cv2.threshold(trimmed_blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
            h_trim, w_trim = trimmed_thresh.shape[:2]
        else:
            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)

        if label in ["イ", "1"]:
            resized = cv2.resize(trimmed_thresh, (new_w, new_h), interpolation=cv2.INTER_AREA)
        else:
            resized = cv2.resize(trimmed_blur, (new_w, new_h), interpolation=cv2.INTER_AREA)

        # 学習用データから切り出した文字を28×28ピクセルのキャンバスの中央に配置する。
        # 入力画像を統一されたサイズに整える。
        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)

        # - 画像のモーメント(統計量) を計算し、文字の重心(中心)を求めてセンタリングする処理
        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_AREA, borderValue=255)

        canvas = deskewed

        # 画像の標準化
        mean, std = cv2.meanStdDev(canvas)
        std = std[0][0] if std[0][0] > 1e-5 else 1.0
        # 画像の正規化
        norm_img = (canvas.astype(np.float32) - mean[0][0]) / std
        norm_img = cv2.normalize(norm_img, None, 0, 255, cv2.NORM_MINMAX)
        canvas = norm_img.astype(np.uint8)
    else:
        # - 有効な画像データがない場合は、白色の 28×28 画像を作成。
        canvas = np.full((28, 28), 255, dtype=np.uint8)

    # png形式で保存する
    save_path = os.path.join(output_folder, f"crop_Img{index:04d}.png")
    is_written = cv2.imencode(".png", canvas)[1]
    with open(save_path, "wb") as f:
        f.write(is_written)
    index += 1

png 形式での保存を選択したことにも理由があります。データを間引いて保存する Jpeg 形式よりも、可逆圧縮を使用し、元の画像データを損失なく保存(ピクセル単位での正確性を維持)できる png 形式の方が機械学習には適しているからです。

こうして、次のような学習用データが完成しました。

こんな小さな画像ですが、ここまで到達するには・・・ 本当に長い時間と・・・ 試行錯誤が必要でした。

5.学習モデルを作る

任意のフォルダ内(ここでは trimed フォルダ)に、正解ラベルの名前を付けたフォルダを必要数分準備して、上の4で作成した学習データを格納しておきます。

そして、学習モデルを作成するスクリプトを実行します。例としてカタカナ「アイウエオ」の推論用の学習用データ作成スクリプトを示します。

import os
import cv2
import numpy as np
import joblib

from skimage.feature import hog, local_binary_pattern
from sklearn.decomposition import IncrementalPCA
from sklearn.svm import SVC
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.metrics import classification_report

LABELS = {'ア': 0, 'イ': 1, 'ウ': 2, 'エ': 3, 'オ': 4}
IMG_SIZE = (28, 28)

LBP_RADIUS = 1
LBP_POINTS = 8 * LBP_RADIUS
LBP_METHOD = 'uniform'

DATASET_DIR = r".\aiueo\Trimed"

def extract_features(image):
    image = cv2.GaussianBlur(image, (3, 3), 0)
    hog_features = hog(image, pixels_per_cell=(4, 4), cells_per_block=(2, 2), feature_vector=True)
    lbp = local_binary_pattern(image, LBP_POINTS, LBP_RADIUS, method=LBP_METHOD)
    lbp_hist, _ = np.histogram(lbp.ravel(), bins=np.arange(0, LBP_POINTS + 3), range=(0, LBP_POINTS + 2))
    lbp_hist = lbp_hist.astype("float32")
    lbp_hist /= (lbp_hist.sum() + 1e-6)
    return np.concatenate([hog_features, lbp_hist])

def load_dataset_in_batches(root_dir, max_samples_per_label=7000, batch_size=500, show_progress=False):
    label_dirs = [d for d in os.listdir(root_dir) if os.path.isdir(os.path.join(root_dir, d)) and d in LABELS]

    for label_name in label_dirs:
        label_path = os.path.join(root_dir, label_name)
        files = os.listdir(label_path)
        np.random.shuffle(files)
        batch_features = []
        batch_labels = []

        total_files = min(len(files), max_samples_per_label)
        if show_progress:
            print(f"\n[{label_name}] 読み込み開始 (最大{total_files}枚)")

        for i, file in enumerate(files):
            if i >= max_samples_per_label:
                break
            file_path = os.path.join(label_path, file)
            image = cv2.imdecode(np.fromfile(file_path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)
            if image is None or image.shape != IMG_SIZE:
                continue
            feat = extract_features(image)
            batch_features.append(feat)
            batch_labels.append(LABELS[label_name])

            if show_progress and ((i + 1) % max(1, total_files // 20) == 0 or (i + 1) == total_files):
                progress = (i + 1) / total_files * 100
                print(f"  {i+1}/{total_files}枚完了 ({progress:.1f}%)")

            if len(batch_features) >= batch_size:
                yield np.array(batch_features, dtype=np.float32), np.array(batch_labels, dtype=np.int32)
                batch_features = []
                batch_labels = []

        if batch_features:
            yield np.array(batch_features, dtype=np.float32), np.array(batch_labels, dtype=np.int32)

# 特徴抽出
print("\n[特徴量収集]")
all_features = []
all_labels = []
for batch_features, batch_labels in load_dataset_in_batches(DATASET_DIR, show_progress=True):
    all_features.append(batch_features)
    all_labels.append(batch_labels)

X_all = np.vstack(all_features)
y_all = np.hstack(all_labels)

# PCA学習
print("\n[PCA学習開始]")
n_components = 200
pca = IncrementalPCA(n_components=n_components)
pca.fit(X_all)

# 特徴量変換
X_pca = pca.transform(X_all)

# データ分割
print("\n[データ分割]")
X_train, X_test, y_train, y_test = train_test_split(X_pca, y_all, test_size=0.2, random_state=42)

# 間違えてもとにかく判定
# model = SVC(kernel='rbf', gamma='scale', C=10)
# 指定正解率未満の場合は「判定不可能」と表示
# この指定着きでビルドしていない場合、判定スクリプトを実行すると「handwritten_digit_0.png の推定結果: モデルが確率推定に未対応」と表示される
model = SVC(kernel='rbf', C=10, probability=True)

'''
# ハイパーパラメータ探索 -> 1, 10, 50, 100 の中から探す。結果、C=10 だった!
print("\n[グリッドサーチ]")
param_grid = {'C': [1, 10, 50, 100]}
svc = SVC(kernel='rbf', gamma='scale')
clf = GridSearchCV(svc, param_grid, cv=3)
clf.fit(X_train, y_train)
print(f"最適なCの値: {clf.best_params_['C']}")
model = clf.best_estimator_
'''

# モデル学習(これが抜けているとエラーになる!)
model.fit(X_train, y_train)

# 評価
print("\n[テストデータで評価]")
y_pred = model.predict(X_test)
print(classification_report(y_test, y_pred, target_names=list(LABELS.keys())))

# 確率推定
# y_proba = model.predict_proba(X_test)  # 各クラスの確率を取得
# print(y_proba[:5])  # 最初の5つの予測結果の確率を表示

# モデル保存
print("\n[モデル保存]")
joblib.dump(model, "aiueo_svm_model.pkl")
joblib.dump(pca, "aiueo_pca.pkl")
print("[保存完了]")

実行結果は、次の通りです。

[特徴量収集]
[ア] 読み込み開始 (最大2511枚)
  125/2511枚完了 (5.0%)
 ・・・(省略)・・・
  2511/2511枚完了 (100.0%)
[イ] 読み込み開始 (最大2575枚)
  128/2575枚完了 (5.0%)
  ・・・(省略)・・・
  2575/2575枚完了 (100.0%)
[ウ] 読み込み開始 (最大2636枚)
  131/2636枚完了 (5.0%)
  ・・・(省略)・・・
  2636/2636枚完了 (100.0%)
[エ] 読み込み開始 (最大2582枚)
  129/2582枚完了 (5.0%)
  ・・・(省略)・・・
  2582/2582枚完了 (100.0%)

[オ] 読み込み開始 (最大2602枚)
  130/2602枚完了 (5.0%)
  ・・・(省略)・・・
  2602/2602枚完了 (100.0%)
[PCA学習開始]
[データ分割]
[テストデータで評価]
              precision    recall  f1-score   support

           ア       1.00      1.00      1.00       516
           イ       1.00      1.00      1.00       538
           ウ       1.00      1.00      1.00       537
           エ       1.00      1.00      1.00       499
           オ       1.00      1.00      1.00       492

    accuracy                           1.00      2582
   macro avg       1.00      1.00      1.00      2582
weighted avg       1.00      1.00      1.00      2582

上のスクリプトで、

X_train, X_test, y_train, y_test = train_test_split(X_pca, y_all, test_size=0.2, random_state=42)

としていますので、データセットの 80% を学習用 (X_train, y_train)、20% をテスト用 (X_test, y_test) に分割していることになります。

スクリプトを実行する度に、テスト用の20%の内容は変化しますので [テストデータで評価] の部分は変化するはずですが、何回か、実行した結果悪くても 0.99 で、どの文字についても、ほとんど 1.00 から変化がありませんでした。

ちなみに、もう1回実行してみると・・・

[テストデータで評価]
              precision    recall  f1-score   support

           ア       1.00      1.00      1.00       516
           イ       1.00      1.00      1.00       538
           ウ       1.00      1.00      1.00       537
           エ       1.00      1.00      1.00       499
           オ       1.00      1.00      1.00       492

    accuracy                           1.00      2582
   macro avg       1.00      1.00      1.00      2582
weighted avg       1.00      1.00      1.00      2582

もう1回、実行してみました。

[テストデータで評価]
              precision    recall  f1-score   support

           ア       1.00      1.00      1.00       516
           イ       0.99      1.00      1.00       538
           ウ       1.00      1.00      1.00       537
           エ       1.00      1.00      1.00       499
           オ       0.99      0.99      0.99       492

    accuracy                           1.00      2582
   macro avg       1.00      1.00      1.00      2582
weighted avg       1.00      1.00      1.00      2582

「イ」と「オ」が 0.99 ですが、それでも 0.99 です。

・適合率 (precision) が ほぼ 1.00 なので、正解ラベルを正しく予測できています。
・再現率 (recall) が ほぼ 1.00 なので、実際の正解ラベルを確実に検出できています。
・F1スコア が ほぼ 1.00 なので、誤分類はありません。

で、総合精度 (accuracy) が 1.00 ですから、今回、作成した学習モデルはテストデータに対して完璧に近い性能を発揮していると言ってよいと思います。ただ、ただ、過学習に陥ってないことを祈るのみです。過学習に陥っていないことの確認するのは簡単です。未知の手書きカタカナ「アイウエオ」のデータを、この学習モデルに見せて、正しく推論できるか、テストしてあげればよいのです。

なので、未知のカタカナ文字を正しく推論できるか、テストしてみました。

文字は、すべて僕自身が書いたものです。


テストに使用したスクリプトです。こちらは簡易版で、実際の場面では、より良い推論用データとなるよう、このスクリプトを適用する前処理として、上で学習用データを作成するために行った補正(修正)を上の5つの画像に対して行い、その後、このテスト用スクリプトを適用することになります。

# アイウエオ判定用最終版

import os
import cv2
import numpy as np
import joblib
from skimage.feature import hog, local_binary_pattern

# 定数
IMG_SIZE = (28, 28)
LBP_RADIUS = 1
LBP_POINTS = 8 * LBP_RADIUS
LBP_METHOD = 'uniform'

# 特徴量抽出関数(前処理スクリプトと整合)
def extract_features(image):
    image = cv2.GaussianBlur(image, (3, 3), 0)

    # HOG特徴量
    hog_features = hog(
        image,
        pixels_per_cell=(4, 4),
        cells_per_block=(2, 2),
        feature_vector=True
    )

    # LBP特徴量(ヒストグラム)
    lbp = local_binary_pattern(image, LBP_POINTS, LBP_RADIUS, method=LBP_METHOD)
    lbp_hist, _ = np.histogram(
        lbp.ravel(),
        bins=np.arange(0, LBP_POINTS + 3),
        range=(0, LBP_POINTS + 2)
    )
    lbp_hist = lbp_hist.astype("float")
    lbp_hist /= (lbp_hist.sum() + 1e-6)

    return np.concatenate([hog_features, lbp_hist])

# モデルとPCA読み込み
model = joblib.load("aiueo_svm_model.pkl")
pca = joblib.load("aiueo_pca.pkl")

# 推論対象ファイル(日本語ファイル名対応)
file_list = [f"katakana_sample_{i+1}.jpg" for i in range(5)]

# 推論実行
for file_name in file_list:
    if not os.path.exists(file_name):
        print(f"ファイルが存在しません: {file_name}")
        continue

    file_path = os.path.abspath(file_name)
    # 日本語パス・ファイル名対応
    image = cv2.imdecode(np.fromfile(file_path, dtype=np.uint8), cv2.IMREAD_GRAYSCALE)

    if image is None:
        print(f"読み込み失敗: {file_name}")
        continue

    # サイズ変換(28x28)と前処理との整合
    image_resized = cv2.resize(image, IMG_SIZE, interpolation=cv2.INTER_AREA)

    # 特徴量 → PCA変換 → SVM予測
    features = extract_features(image_resized)
    features_pca = pca.transform([features])
    prediction = model.predict(features_pca)

    print(f"{file_name} の推定結果: {prediction[0]}")

学習モデル( aiueo_svm_model.pkl と aiueo_pca.pkl )は、スクリプトと同じ場所に置いて実行します。結果は、次の通りです。

katakana_sample_1.jpg の推定結果: 0
katakana_sample_2.jpg の推定結果: 1
katakana_sample_3.jpg の推定結果: 2
katakana_sample_4.jpg の推定結果: 3
katakana_sample_5.jpg の推定結果: 4

よかった・・・。過学習には陥っていないようです。

あとは・・・ Delphi の P4D( Python4Delphi )を使って、僕の手書き答案採点補助プログラム AC_Reader で、このスクリプトを実行すれば・・・

自動採点を、実現できます。

6.まとめ

機械学習で良い学習モデルを作るには、学習用データ作りをしっかり行うことが大切。文字の大きさ・位置(配置)・濃さ等の調節、及び、画像中の不要な点(シミ)や汚れを除去する等々して、個々にブレのない安定した学習用データ(もちろん、推論用データも同様)を作成、これを元に学習モデルを作成すれば上に示した結果を出せるはずです。

今回の記事で紹介した内容は、テストを繰り返し行って、問題点を洗い出し、それら問題点を1つ1つ丁寧に解決した結果です。絶対に『あきらめない』こと、もしかしたら、それがいちばん重要で大切なポイントかもしれません。

7.お願いとお断り

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

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

前回の記事を書いてから、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.お願いとお断り

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

観点別評価と評定の整合性をチェックする

高等学校における現行の教育課程では、3つの観点それぞれについて、A・B・Cで評価し、トリプルA(AAA)ならば評定は「5」というような成績評価を行っています。ほとんどの現場では、表計算ソフトを使って観点別評価を点数化し、その合計に応じて評定を自動的に計算する仕組みを導入しているのではないか? と思いますが、そうだとしても成績の最終的なチェックは絶対に必要。

そこで、観点別評価と評定を入力したファイル( Excel Book の拡張子が xls, xlsx, xlsm いずれかのファイル)を任意のフォルダに入れ(もちろん、複数個入っていてもよい)、ここで紹介する「観点別評価と評定の整合性をチェックするプログラム」を起動、フォルダを選択するだけで、データのセル番地など、一切指定しなくても各々のファイルに入力された観点別評価と評定の整合性を全自動でチェック(整合性に問題がある場合、オプションで指定すれば観点別評価に基づいて評定を自動修正)してくれるプログラムを書いてみました。

チェック完了時、問題がなかった場合に表示される画面


実際に使ってもらい、「これはイイ!」と評価していただけましたので、ここでフリーソフトとして公開します。「 Excel Book に入力された観点別評価と評定の整合性をチェックするよい方法はないか?」と、悩んでいらっしゃる方にお使いいただけたら、何よりの幸いです。気がついた不具合はすべて解消してありますが、未発見のバグがまだどこかにあるかもしれません。このプログラムはあくまでも「素人」が、「趣味」で書いたものであり、思い込みや勘違いによる誤りを内包している可能性があります。大変、申し訳ないのですが、どうか、そこだけはご了承ください。

【もくじ】

1.観点別評価から評定への変換基準
2.ワークシートへのデータ入力方法
3.プログラムの使い方とダウンロード方法
4.まとめ
5.お願いとお断り

1.観点別評価から評定への変換基準

観点別評価から評定への変換基準は、次の通りです。

Aは6点、Bは4点、Cは1点に変換、その合計値が18ならば評定5、そうでない場合はその合計値が13点以上ならば評定4、そうでない場合はその合計値が9点以上ならば評定3、そうでない場合はその合計値が6点以上ならば評定2、そうでない場合は(合計値が3点ならば)評定1とする変換基準に基づいて、このプログラムは動作します。

観点別評価から評定を計算するのではなく、あくまでも、既存の成績データの整合性をチェックするプログラムであることに、どうか、ご留意ください。

【観点別評価と評定】
AAA ・・・ 5
ABA, BAA, AAB ・・・ 4
AAC, ACA, CAA ・・・ 4
ABB, BAB, BBA ・・・ 4
ABC, ACB, BAC, BCA, CAB, CBA ・・・ 3
BBB ・・・ 3
BBC, BCB, CBB ・・・ 3
ACC, CAC, CCA ・・・ 2
BCC, CBC, CCB ・・・ 2
CCC ・・・ 1

2.ワークシートへのデータ入力方法

次の2つのパターンに対応。

StringGridを2つ並べて作成したUI


「まとめて入力」を選択した場合は、観点別評価がまとめて一つのセルに入力されているファイルをチェックし、「分けて入力」を選択した場合は、観点別評価がそれぞれ独立したセルに入力されているファイルをチェックします。

いずれの場合も観点別評価が文字列または文字データとして入力されたセルの「真」に右隣りのセルに「評定」の数値データが入力されていることが、プログラムが正常に動作するための必須条件。

プログラム完成後にセルに埋め込んだ計算式が表示する値であっても、上の動作条件を満たす形でデータが並んでいれば、プログラムは正しく動作することを一応確認しました、が・・・

評定を計算式で表示しているのであれば、こんなチェック・プログラムはいらないか、と・・・。

【重要な注意】

このプログラムは、任意の行のセルに入力された、文字列(または文字)の観点別評価と「真」に隣り合う列に、数値で入力されている評定がある箇所を見つけ、その整合性をチェックするものとして開発。

プログラムが正しく動作する入力例:

評価は文字列か文字、評定は数値(いずれも計算式が表示する値ではないという前提)

次の場合は動作しません!
ワークシートのセルに設定された計算式がある場合は、それを破壊します。

プログラムが正しく動作しない(どころかデータの破壊が生じる)入力例:

列が非表示に設定されている


上の例のように、観点別評価と評定の入力セルの間に「非表示に設定された列」があり、その非表示に設定された列に観点別評価のA・B・Cを数値に変換する式が組まれているような場合、非表示の列があるため、見た目には観点別評価と評定が隣り合うセルにあるように見えても、プログラムは期待通りに動作しません。このようなファイルを自動修正機能を使用してチェックした場合、非表示の列内のセルに設定された計算式は確実に破壊され、失われます。くれぐれもご注意ください。

前述の通り、このように式で結果を表示している場合は、チェックする必要性などない気が・・・しますが、どうしてもチェックしたい場合は、ファイルのバックアップを取り、ワークシート全体を値複写で上書きしてから、不要な列を削除すれば、チェック可能に。

プログラムは、観点別評価が入力されているセルを自動的に探し、その「真」に右隣りに存在するセルに入力されている数値が期待されたものであるか・どうかをチェックするだけで、この並びに従わないその他のセルに入力されたデータ・計算式はすべて無視して動作します。

問題は、(私の)想定外の(プログラムが)無視できない「何か」に引っかかってしまった際の挙動ですが、重要な部分は try 文を使用して、何かあればエラーメッセージを表示するようにプログラミングしてあります。なので、プログラムがフリーズするようなことは、起きないはずです。

また、観点別評価が入力されていると判定されたセルの「真」に右隣りのセルに、(評定の入力がない)空白セルがあった場合は、エラーメッセージを表示します。動作確認作業を進める中で、そのことの必要性に気づき、プログラムに必要な修正を加えました。もちろん、空白セルの自動修正も可能です。ただし、観点別評価が不足している(3観点分がそろっていない)場合には、プログラムはそのようなセルをチェック対象としません。もちろん、エラーメッセージも表示されません。使用にあたっては、この点にも十分ご注意ください。

3.プログラムの使い方とダウンロード方法

チェックしたいファイルを任意のフォルダに保存します。

拡張子は3種類に対応


上のように、チェックしたいファイルを保存したフォルダ内にその他のフォルダやファイルがあっても問題なく動作します。

チェック・プログラムのアイコンをダブルクリックしてプログラムを起動します。


最初に、チェックしたいファイルの拡張子を選択します。

xls, xlsx, xlsm 3種類のファイルに対応


次に、観点別評価の入力形式を選択します。

観点別評価の入力方法の選択肢は、直感的に選べるよう工夫したつもり・・・


次に、調査対象のファイルを入れたフォルダが exe と同じ場所にあれば「 EXE 位置」、そうでない場所にある場合は「指定なし」をクリックしてください。


次に、調査対象とするワークシートの番号を指定します。


Excelのワークシートコレクションのインデックス番号は「0」始まりではなく、「1」始まりであることに注意してください。1枚目のワークシートとは、次の図の「 Sheet1 」を意味します。このプログラムではワークシートの名称ではなく、その位置でチェック対象のシートを決めています。ですので、ワークシートの名称は問いません。


より詳細な案内表示や、評定の自動修正機能を使用したい場合は、チェックボックスにチェックを入れてください。デフォルトで「案内」は True、「自動修正」は False に設定してあります。


上で述べたように、「自動修正」は最悪の場合、ワークシートのセルに設定された計算式を破壊する可能性があるので、取扱いには十分注意する必要があります。いろいろ考えた末、やはりここはパスワードを入力しないと自動修正機能が有効にならないようにするのが万一の事故を防止するには最善と判断しました。なので、「自動修正」にチェックするとパスワード入力を求める自家製 InputQuery が表示されます。


次のパスワードを入力して、OKをクリックしてください。
最後に半角の「 ! 」がついています。コピペする際、お忘れにならないよう、ご注意願います。

Evaluate-Fix2025!

正しいパスワードが入力されていれば、次のメッセージが表示されるので、

ものすごく、読みにくいとは思いますが・・・

よくよくよくよくよく
お読みいただき、
ご理解・ご了承いただけた場合のみ

「はい」をクリックしてください。万一にも、不安を感じた場合は「いいえ」を選んだ方が賢明です。

【参考】

ちなみに、「自動修正」にチェックが入っていなければ、ファイルは読み取り専用で開くようにして、不測の事故を防止しています。

  if cbAutoWrite.Checked then
  begin
    //ファイルを書込み可能な状態で開く
    Workbook := ExcelApp.Workbooks.Open(ListBox1.Items[i], EmptyParam, False, EmptyParam,
    EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, EmptyParam, 
    EmptyParam, EmptyParam, False);
  end else begin
    //ファイルを読み取り専用で開くことで、編集のためにロックされることを防ぐことができる
    Workbook := ExcelApp.Workbooks.Open(ListBox1.Items[i], ReadOnly := True);
  end;

「はい」をクリックすると、案内のメッセージが表示されます。


準備が全て整ったら、「チェック開始」ボタンをクリックしてください。最初にチェックするファイルを保存したフォルダをクリックして選択し、OKをクリックします。チェックは自動的に始まります。

成績ファイルを保存したフォルダを選択


チェックが完了すると、次のメッセージが表示されます。それまでお待ちください。実測したわけではありませんがチェックするデータがワークシート1枚あたり1000セットあると、PCの性能にもよりますが 30 秒以上かかると思います。


なお、チェック中は、プログラム画面の下方にプログレスバーが表示され、緑の帯が作業の進行状況を示してくれます。

実際に、ある現場で使用しているプロが作成した業務用ファイルに対して実行してみた例。
1学年7クラス規模の場合、約400行 × 約80列程度のチェック範囲となっている。


チェックの結果、問題がなかった場合は「評価と評定の整合性に問題はありませんでした。」と表示されてチェック終了です。


整合性に問題があるデータを見つけた際は、その行・列位置を次のように表示します。

RはRow(行)、CはCol(列)を意味します。
(本番で、こんなに間違いがあることは、まず『ない』と思いますが・・・)


最も上の例で言えば「セル R2C3 」は、第2行目の第3列のセルのデータに問題があることを意味し、プログラムは、その問題の内容を右の( )内に表示します。この場合は、「6」というあり得ない評定値が入力されていたことが問題の原因であることを示しています。

「自動修正」を有効化してチェックした場合は、次のように修正後の評定も表示されます。

自動修正を有効化した場合は、保存する時間も必要なので動作速度が若干低下します。


これで、成績の付け間違いは完全に撲滅できると思ったのですが(確かに実際に撲滅できましたが)、現場でこのプログラムが発見したデータの誤りの中には、「評定が正しく、観点別評価の方が間違い」だった例がありました。ですので、整合性の問題を発見した際には、誤りが「観点別評価」にあるのか、それとも「評定」にあるのかを個別にチェックする必要があるようです。

なお、設定は ini ファイルに保存し、次回起動時に復元することができます。作者が勝手に設定した値になりますが、諸設定を初期化することもできます。

初期化を実行するには、「ロックの解除」が必要

【プログラムのダウンロード】

ダウンロード要件に同意していただける場合のみ、ダウンロードできます。

ダウンロード後、zip ファイルを展開していただき、ABC_Cheker.exe をダブルクリックしてプログラムを起動してください。同梱の TestData フォルダ内にテスト用データを入力済みのファイルがありますので、このファイルを利用してプログラムの動作をご確認ください。

なお、プログラムの初回起動時には、Windowsのセキュリティ機能であるSmartScreenにより「WindowsによってPCが保護されました」というメッセージが表示されると思います。

初回実行時に表示される警告画面


悪意のあるプログラムではありませんので、「詳細情報」をクリックすると表示される次の画面で「実行」を選択(クリック)し、プログラムを起動してください。2回目の実行からは、この警告画面は表示されなくなるはずです。

「実行」をクリックしてプログラムを起動します。


お手数をお掛けして申し訳ありませんが、信頼できる発行元になるために必要なデジタル署名を取得する費用等を考えますと、個人レベルで、その申請手続きを行うことは私の場合、無理と言わざるを得ません。開発に使用している IDE ( Delphi 12.3 )のサブスクリプション費用の支払いだけは Object Pascal の発展を願う1ユーザーとしての気持ちからずっと続けていますが・・・。

なお、最初にアップロードした実行形式ファイルで「自動修正」を有効にした状態で設定を保存すると、次回起動時に Form が表示される前に自動修正を有効化する処理が行われてしまい、「無効/非表示ウィンドウにはフォーカスを設定できません。」というエラーメッセージが表示されてしまうバグがあることに気づき、「自動修正」を有効にした状態で設定を保存しても、次回起動時に Form の表示が完全に行われてから、自動修正を有効化する処理が実行されるように、プログラムを修正しました。

ただ、「自動修正」を常に有効化した状態で起動すると、毎回パスワードを入力する InputQuery が表示されることになってしまいます。そういう「仕様」ですので、これは仕方がありませんが、起動と同時にパスワードの入力を求められますので、ちょっとびっくりします。ですので、危険を防止する意味からも「自動修正」機能を常に有効化しておく設定での運用は避けた方がよろしいかと思います。

4.まとめ

・新教育課程の観点別評価と評定の整合性をチェックするプログラムができました。
・高等学校用です。
・無料でお使いいたけますが、サポート等は一切ありません。
・ヘルプファイルもありません(ここでの説明がすべてです)。
・作者が未発見の(大いなる)不具合がある可能性があります。

5.お願いとお断り

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

10より大きいマークを使うマークシートの作り方

以前、文書作成ソフト( Word )や表計算ソフト( Excel )を使用してオリジナルのマークシートを作成する我流も我流、はたしてこんなんでいいのか? まぁ、実際に使えるから、いいか・・・みたいな記事をいくつか書いた。

書いた本人が言うのだから間違いないであろう、過去のいい加減な記事の数々・・・


で、今回はナニをしたかと言うと、数学用マークシート処理プログラムの改良版を作成するにあたり、マークシートそのものも改良(と本人は思っている)し、プログラムもあらかたできた(と本人は思っている)ので、「実際の試験でテストしたいなー」と思ったわけですが・・・

「実際の試験でテストする」って言い方もヘンですが。

「実際のテストで試験する」って言っても、やっぱりヘンですが。

・逆もまた真なり? どっちもヘン

 まぁ、なんでもイイです。

いきなり数学の先生に「試しに使ってみてください」というお願いをするのもナンだし・・・

万一どころか、使って初めて気づく
バグ満載のプログラムであることは「間違いない」自信だけはあり・・・

( なら 自分で、こっそり )

プログラムのテストを決行することに決めました。


決めたのはいいんですが、使用するマークシートが問題で、数学用途のシートは個人的な問題から使用できないため、マークが「 -(マイナス記号)から始まり、dで終わる」数学用のシートではなく、それと見た目が同じ(大問番号や設問記号及び枠の大きさが同じ)で、ただマークのみ「1から始まり16で終わる」カタチに変更したマークシートを作成し、これで新しい採点処理プログラムをテストしようと思ったワケです。

しかーし、ここで大問題が二つ発生!

大問題その1:
・私の技量では表計算ソフトで、10以上のマークが作成できない!

大問題その2:
・文書作成ソフトで、マークシートを修正する方法を全部忘れた!!

その1は純粋に技術的な問題で、「今後の学び & 創意工夫」により改善が見込めるからまだイイとしても、あろうことか、その2は青天の霹靂・悲惨の極み・驚天動地・寝耳に水・予期せぬ不意打ち などなど、日本語ではいろいろな表現が可能だが、まぁ最も適切なのは「痛恨の一事」か・・・

なんで全部忘れるの オレ?

ってか、修正方法をもともと知りませんでした☆ ぎゃはは

・・・というわけで、たとえこのように七転八倒と運命づけられた人生であっても、まだあきらめる気がしない(ここにメモしておけば、また忘れても必ず思い出せる & 万一にも同じ志を抱く、どなた様かのお役に立てれば・・・それこそ幸い的な思いもあり)、今回のテーマは「10より大きいマークを使うマークシートの作り方」です。

【もくじ】

1.(私には)表計算ソフトで10以上のマークが作れない!
2.マークの修正方法を全部忘れてることに気づく
3.イチから出直します
4.まとめ
5.お願いとお断り

1.(私には)表計算ソフトで10以上のマークが作れない!

自身が最も多用するのは、1ブロックが 25 行で、1設問あたり8選択肢、合計4ブロックの全 100 問対応の A4 横置き型マークシート。(My 用途では、実はコレでほんとに十分なのですが・・・)

25行、8選択肢、4ブロック、100問対応のマークシート


あれもしたい、これもしたい、みたいな、欲に目が眩んで、というか、思いつくままにマーク読み取りプログラムの機能を拡張したくなり、このシートを元にして作成した発展形の一つである数学用は、1ブロックが 25 行で、1設問あたり 16 選択肢、合計3ブロックの全 75 問対応の A4 横置き型マークシート。1枚で大問3個しか設定できないので、2枚を組み合わせて採点することで大問6個に対応。

選択肢は、-・±・0~9・a・b・c・d の16個(文書作成ソフトで作成)。
実は、マークとマークの間隔が狭いところ等を直したいって、ずっと思っていた。


現在、この数学用マークシートを改良して、B4 縦置きの用紙にB5横置きを縦に2枚並べて印刷し、半分に折りたたんだ状態で試験を実施、シート回収後、マークの読み取りと採点処理を実行できるプログラムを書いている。

この新しいプログラムをテストするにあたり、いろいろ直したかったところが満載だった数学用マークシートそのものも改良したくなり、反省点を元に作成したのがコレ(図は設計時の画面)。

表計算ソフトで作成。選択肢の数は16個で旧版と同じ。

反省点とは何かというと、

(1)マーク読み取り範囲の設定方法がわかりにくかった(と思う)ので、まず、これを改良。

旧版では、マーク読み取り範囲の設定時、利用可能な枠線がなかった!

旧版では、左上の「|」マークを目印に読み取り枠を設定した。

赤枠で囲んだ範囲がマークの読み取り範囲


新版は、枠線があるので、読み取り範囲の設定が少しはラクになった?
同時に、マークの間隔もより広めに設定し、受験者が多少大きめに塗りつぶしても誤判定が出にくく改良(したつもり・・・テストしていないので、現時点では効果のほどは?)。

きちんとした枠線を設け、マークの間隔を広くした!


なので、読み取り範囲の設定は、枠線を利用して実行できるようになった。


(2)1ブロックあたりの行数を 25 → 30 行に増やした。 これで大問1個について、30 設問の設定が可能になった。

ア・カ・サ・タ・ナ・ハ行で1ブロック30行
つまり、大問1個について、30設問を設定可能とした。


(3)旧版の A3 縦( A4 横置き×2)ではなく、B4 縦( B5 横置き×2)へ用紙サイズを変更した。

B4縦にB5のマークシート2枚を配置

A3 サイズのシートも作成してみたのだが、A3 サイズだとインクジェット複合機を利用して印刷(輪転機での印刷はマークの濃度が濃くなり、誤判定が出やすくなることから非推奨・・・というか、ユーザーには禁止と案内している)する時間が B4 サイズのそれより明らかに遅くなる、スキャナーでの読み取り処理にも時間がかかる等、いろいろ問題があり、少々マークの文字は小さくなるが A 版に比べて何かとメリットが多い B 版の用紙を使うことに決定。

もちろん、国際的にはやはり A 版だと思うが、欧米文化圏で My マークシートリーダーが使われるシーンはさすがに想像できない。できないが、今年、いちばんの夢は英語バージョンを作成することだ。これは新年早々に思いつき、数学用シートの処理プログラムが完成したら、今年の次のチャレンジ・イベントはそれだと思っている。

で、話を本題へ。

この表計算ソフトで作成した数学用マークシートのマークを「1」から「16」に変更しようとしたのだが、どうがんばってもそれが出来ない!

実際のシーンを再現。

表計算ソフトを起動して、全行・全列のセルの高さと大きさを適当なサイズに設定し、挿入 ⇨ 図形から楕円を1つ、セル内ちょうどおさまるように描画、このオブジェクトを右クリックして表示されるサブメニューから、「テキストの編集」を選択(クリック)して半角数字で「1」を入力。オブジェクトの色は灰色に設定する。


次にマークのオブジェクトが入っているセルを選択し、オートフィルの機能を使って右へドラッグしてコピーする。

とりあえず16個、コピーした。

ここまでは、実にイイ感じ♪

左から2つめのマークの数字部分をクリックして編集状態にし、半角数字の「2」を入力。


これを3、4、5、・・・、9まで繰り返して、10を作成すると・・・

「9」まではイイ感じだが☆ 10で問題が発生。

おい、ちょっと待て・・・

「0」は「1」の下じゃなくて、「1」の横に表示して欲しいんだけど・・・


しかも、フォーカスを外すと・・・

ヘイ バカターレ!
8、9、1じゃないよー!!

楕円のオブジェクトの幅を変えるわけにはいかないから、フォントサイズを小さくして修正。

ハイ
不採用決定。(T_T)

このまま、あきらめるのはどーしてもイヤだったので、ジタバタしてみることにする。
どーせ、他にすることないし。実はあったかもだけど、したくないし・・・

しばし、沈思黙考

(-_-)zzZ

寝るなー!!

オブジェクトの中に数字を描画するのがイケナイのかと思ひ・・・、楕円オブジェクトは「塗りつぶしなし」に設定して、テキストはセルに直接入力してみる。

半角数字をセルに入力


ちょっと、微妙に違和感がないこともないが、なんとか使えるかな・・・という程度にはなったか?
2桁数字の方が、なんとなく、下がって見える・・・ 色も濃い?(同じ灰色でも面積の関係か?)


試しに、印刷プレビューしてみると・・・

2桁数字のインパクトが強すぎ!!

(塗りつぶし面積も、実用的にはもっと狭い方が好ましい)


こんなマークシートでは、存在感の薄い「1」~「9」にマークするには、余程の勇気が必要です!

ハイ
不採用決定。(T_T)

上の例なんてまだ良いほうで、実際には、もっとイロイロやってみたが、使えないマークシートをひたすら量産する結果に。(元々ない)知恵の限りを尽くしても、状況は改善する兆しすらなく・・・

少なくても現在の私の技能では、表計算ソフトを用いて「実際に使いたいと感じるレベルの品質」を維持した「10以上の数値を表示するマークを作成することは不可能」と悟ったのであった。

2.マークの修正方法を全部忘れてることに気づく

まだ、すべてが終わったわけではない。そうだ。文書作成ソフトを使って再チャレンジする方法が残されている。以前、教科「情報」用のゼロ始まりのマークシートを作ったじゃないか。あの時は特に問題なく、0、1、2、・・・、14、15まで計16個の丸囲み数字を作成できたはずだ。

そう思い、保存してあった教科「情報」用のファイルを開き、それを改良しようとしたのだが・・・

手も、足も、出ないとはこのことか・・・

ヤバイ!

いじれない!!

修正方法、全部、忘れた!!!

・・・ってか、よく考えたら、もともと知らない。
コレ、作り直した方が早くね??? みたいな・・・

3.イチから出直します

既存のファイルはいじれそうにない。・・・となれば、残された道はただひとつ。

白紙状態から全部書く!
それしかない!!

あの日、近所の国道を爆走していた緑色の大型トラックの運転席の後ろに力いっぱい掲げられた看板にも、「イチから出直します!」って、確かに書いてあった。・・・あの時、感動で魂が震えたな・・・

実際、ナニがあったのか、わかりませんが・・・

My ふぇーばりっと Car の運転席から思わず叫んでました☆

運転手さん、がんばって!!

・・・ということで 走召 有名な!あの文書作成ソフトを起動し、新規作成で用紙を「 B5 横置き」に設定。余白は最小値(My環境では 0.3 mm)にする(行数・列数共に詰め込みたくて、この設定にしています。実際のシーンではもう少し余裕マージンを取り、あまり攻めすぎない方が良いと思います)。

「レイアウト」タブをクリックして、「ページ設定」リボンの中の「段組み」アイコンをクリックし、表示されるサブメニューから「3段」を選択する。

とりあえず段組は3段を指定


これだけだと何も表示がなく、段組みの状況がわかりにくいので、再度同じ操作を行い、今度はいちばん下の「段組みの詳細設定」をクリック。

「境界線を引く」をチェックしてOK


画面に境界線が描かれる(最終的に消しますが・・・)。


「タイトル・大問番号・OpenCV用のマーカー画像」を1~3行目に入力。

■■■ はマークシートのマーク位置を決定する指標として利用する


4行目にカーソルを置き、「挿入」タブをクリックして「表」リボンの「表」をクリックして表示される「表の挿入」の枠をドラッグして1行×7列の表を挿入する。

画面はこんな感じ


画面右下の「ズーム」のスライダーを右へドラッグし、画面の拡大率を大きくして・・・


表内の任意の場所をクリックすると表示される「表の移動ハンドル」をクリックすると、表全体が選択されるので、「テーブルレイアウト」タブをクリックして表示される「配置」リボンの「中央揃え」をクリックする。これで表への入力値はすべて中央揃えで表示される。


表の例えば一番右のセルを右クリックして、表示されるサブメニューから「挿入」をクリック、さらに表示されるサブメニューの「右に列を挿入」をクリック。表の列が1つ増えるので、Ctrl+Y を繰り返し実行して表の列数を 17 列にする。

上の操作を1回行ったら、Ctrl+Y で直前の操作を繰り返し実行できる


画面はこんな感じになる。


いちばん左のセルに半角カタカナの「ア」を入力し、左から2番目のセルに丸囲みの1(= ① )を入力する。以降、セルを右へ移動しながら順次丸囲みの数字を 16 まで入力する。

My 環境では、みんな右へ寄った形で表示される・・・


【注意:解答欄の番号・記号について】

「ア」としたのは、自作のマークシートリーダーで使用している数学用シートの流用型として使用するため。数学用とマークシートでは、大問1の ア 、イ 、ウ 、・・・、大問2の ア 、イ 、ウ 、・・・、大問3の ア 、イ 、ウ 、・・・、これで解答用紙 A 面(第1面:1枚目)が終了、続けて B 面(第2面:2枚目)へ移動し、大問4の ア 、イ 、ウ 、・・・、大問5の ア 、イ 、ウ 、・・・、大問6の ア 、イ 、ウ 、・・・ のように設問を設定している。

もちろん、ここは「1」から連番で作成しても構わないのだが、自分的には「2枚1セットで使用する予定の数学用マークシートの採点処理を行う新しいプログラムが、実際の採点現場で正しく動くことを確認する」ために今回は行動しているので、採点プログラムのデータ入力欄との整合性等も考えると、シートの変更点は解答欄のマークのみに留めたかったので、この仕様とした。

ちなみに動作テストを予定している新しい採点処理プログラムの採点データ等の入力画面は、こんな感じ。数学用途の採点の場合、設問の欄は数値の連番ではなく、解答用紙に合わせてカタカナ表記としている。ここが数値の連番だと、正解他のデータが入力しづらい。CMS は「組み合わせ採点」、NPO は「順不同採点」の実施の有無を見分けるフラグ(ここが1ならば順不同採点「有」)として利用する。特に「組み合わせ採点」は、数学用途では必須の機能なので、ここを念入りにテストしたいと考えた。

組み合わせ採点を実施(=CMS列の番号が同じ行)する場合、
配点は組み合わせ採点を実施する範囲内の任意の1行に入力し、他は0を入力。
かつ、組み合わせ採点を実施する範囲の観点別評価の種類は必ず一致させる。

解答を要しない(=使用しない)解答欄を見分けるフラグは「-1」としている。


表の任意のセルを再びクリックし、表の左上に表示される「表の移動ハンドル」をクリックして、表全体を選択。「テーブルレイアウト」タブをクリックして、「配置」リボンの「セルの配置」をクリック、表示される「表のオプション」ダイアログの「既定のセルの余白」の左と右の値を0(ゼロ)に設定して OK 。

この設定方法は、これまで知らなかった!

あれこれ、設定を弄り倒す中で、先日、偶然発見 *(^_^)*♪


表はこうなる。

イイ感じ


次に表の ① ~ ⑯ セルをドラッグして選択し、


「ホーム」タブをクリックし、「段落」リボンの「拡張書式」をクリック、表示されるサブメニューの「文字の拡大/縮小」をクリック、さらに表示されるサブメニューの「66%」をクリックする。


表はこうなる。

さらにイイ感じ

気分は Good! Goooder!! Goooodest!!!

あとは不要な罫線を消し、罫線とフォントの色をごく薄い灰色に設定するのみ。

ここは可能な限り薄い灰色に設定したい


罫線の色を変更するには、表全体を選択して、表中で右クリック。表示されるサブメニューから「表のプロパティ」を選択(クリック)。


表のプロパティが表示されたら、「罫線と網かけ」をクリック。


「色」と「線の太さ」を変更して、「プレビュー」の必要箇所をクリックしてOK。


表はこうなる。


今度は、もう一度表を全選択し、選択範囲内で右クリックして表のプロパティをもう一度表示し、「罫線と網かけ」をクリックして、線の色を「白」に設定、表内の縦罫線を表示しない設定にする。

最後に「ア」のセルのみ選択して、上と同様の操作を実行し、「ア」の右に灰色・ 0.25 ポイントの太さで縦罫線を引く。最終的な画面はこうなる。

コレを作りたかった☆

あとは、この1行を全選択し、選択範囲内で右クリック、表示されたサブメニューの「挿入」をクリックして、さらに表示されるサブメニューの「下に行を挿入」を選択(クリック)。


結果は、こうなる。


追加した行に1行目の内容をコピーしてもよいし、Ctrl+Y で直前の操作を繰り返して必要な行数分、行を作成してもよい。とにかく、行を増やして、そこに1行目のマークを貼り付けて行く。罫線は消えたら消えたで最後にまとめて設定すればよい。

もし、行数が足りない場合は・・・

「ノ」が欲しい とか「ハ行」も欲しい場合がある


Ctrl+A でオブジェクトを全て選択して、選択範囲内で右クリックし、下の図の赤い枠で囲んだ部分のチェックをすべて外して OK をクリックすると、行の高さが小さくなる(はず)。

赤枠内のチェックを全て外す。


次に表のみ、上から下までドラッグ等して選択し、選択範囲内で右クリックして表のプロパティを表示して、「行」タブをクリック。高さを「固定値」として、最適な数値を入力してOKをクリックして行の高さを修正する。

【注意】

理由は定かでないが、この方法で行の高さを「修正できる」場合と、「出来ない」場合があった。


他にも、表のみ全選択するところは同じだが、「テーブルレイアウト」タブの「セルのサイズ」リボンの「高さ」でも同じことができる(こともある?)。


【注意】

理由は定かでないが、やはり、この方法で行の高さを「修正できる」場合と、「出来ない」場合があった。出来ない場合は、Ctrl+Z(元に戻す)で、修正できる場合の直後のところまで戻して実行すると変更が適用された。原因は私にはわからない。

最終的に、1設問あたりの選択肢数は 16 個、1ブロック 30 行、全3ブロックの B5 横置きのマークシートが完成。

このブログ用に作成した参考作品
(実用化するには ■■■ の位置調整が必要)


上の図は、「レイアウト」タブをクリックして、「ページ設定」リボンの「段組み」をクリックして表示されるサブメニューから「段組みの詳細設定」をクリックしてダイアログを表示し、「境界線を引く」のチェックを OFF にした状態の印刷プレビュー。

冷静になって考えると、ヒトはわずかながらでも、進歩し続ける生き物らしい。
以前、出来なかったことが、今は、できるようになった。

きっと、「イチから出直します」トラックの運転手さんのお陰です。

ほんとに、こころから、ありがとう!!

ここには掲載できないけれど、あの日撮った、爆走トラックの写真。

生涯、宝物にします!

4.まとめ

(1)表計算ソフトでは、10 以上の数値を表示するマークの制作は(私には)難しい。
(2)文書作成ソフトなら、比較的簡単に10 以上の数値を表示するマークが(私にも)作成可能。
(3)文書作成ソフトの行の高さの修正は、出来る場合と出来ない場合があった。理由は不明。
(4)イチから出直すことも、より良い人生を歩むためには必要になることがあるカモです☆
(5)大型トラックの看板からは深い学びを得ることがあります。

5.お願いとお断り

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

ファイル名が連番であることを確認したい!

自作のマークシートリーダーでは、Windows 用の OpenCV に加え、Python 用の OpenCV も利用して、マークの読み取りを高速化している。

この Python 用の OpenCV を動かすには Python4Delphi(P4D) が必要だ。P4D 使用時はプログラムの仕様として、読み取り対象のマークシート画像ファイル名の末尾は「数値化したら連番として読み取れる半角3桁の数字」でなければならない(例:X_001.jpgなど、MS_Reader.exe Version 1.1.5 から)。

そのことを、つい忘れて実行すると・・・

【コンパイル済みの exe を実行した場合】

最初に表示されるエラーメッセージ


さらに、

2つめのエラーメッセージ


OKをクリックすると、もう一度、

3つめのエラーメッセージ


んで・・・

4つめのエラーメッセージ


泣きたい気持ち T_T で OK をクリックすると・・・

メモリーリークまで発生・・・

うわーん T_T

【実行(F9)では?】

エラーメッセージの形式こそ、違え・・・

泣きたい気持ちは、同じではありませんか。みなさん・・・


ファイル名が「プログラムの仕様と異なっている」ために起きるエラーであるという、言わば「確実に発生を予見できるエラー」なのに、

どうして今まで、
何とかしようと思わなかったのか?

以前から、なんとなく、気づいてはいたけれど・・・

オレはもしかして、
自分で思ってる以上に
バカ
なんじゃないか?

あらためて、そう思ったのであります。みなさん・・・

そこで、この 犯罪に近い プログラムの挙動をなんとかするべく、ようやくと言いますか、今更ではありますが立ち上がり・・・ 悪戦苦闘すること幾年月(実際、半日くらいです)。なので、今回は、このふと思い立ったちいさな夢を実現するまでの お読みいただく価値などまったくない 苦闘の成果の記録です。

【もくじ】

1.そして、悲劇は繰り返される
2.連鎖の終止符は?
3.まとめ
4.お願いとお断り

1.そして、悲劇は繰り返される

人間は、いろいろなことを忘れる生き物です。

むかーし、サーフィンに夢中だった頃、台風の海で大波と一緒に落ちてきたサーフボードが脳天を直撃。溺れて、死ななかったのはよかったけれど、とにかく砂浜までなんとか生還後、確かに見覚えのある風景を感じはするし、自分の名前も、家の住所も思い出せるのに、「僕のおうちまでの帰り道がどうしても思い出せません!」みたいな・・・。うぎゃー

( この道、見覚えだけはあるんだけどなー。はたして、おうちは右だっけ? 左だっけ? )

( 家の玄関の風景も覚えてるんだけどなー。そこへの行き方がまったくわかりましぇん T_T )

あの時はやばかった・・・ まぁ、あの時ほど、困るわけではないが、それでも半年に2回くらい、My マークシートリーダーを使っていて、ファイル名の命名規則をド忘れし、今回、冒頭で紹介したエラーメッセージをくり返し登場させてしまう・・・。

その都度、あわてふためき、もう二度とするまいと固く心に誓い、反省し、失敗の原因の記録まで書き、クラウドにはそのバックアップまでとり、それでも、七転び八起きではなく、七転八倒を身上とするかのごとき私は、果てしない後悔の輪廻、そう苦しみと迷いの連鎖の中で、なお、その悲劇を執拗なまでに繰り返してきたのであった。

そもそも、X_01A.jpg、X_01B.jpg みたいな、連番と紛らわしいファイル名を付けるプログラムを作ったのも、 なので、やはり、この負の連鎖は、自分自身に問題の深すぎる根っこが・・・

ぞーぉさん
ぞーぉさん
おーなかがデカいのね・・・

なんかちがう、みたいな・・・

ファイル名が連番でなければ読めないマークシートリーダーであるとわかっているのに、しかも、作ったのが他ならぬ自分自身であるにも関わらず、なぜか、「 X-01A.jpg, X-01B.jpg, x-02A.jpg, X-02B.jpg ・・・」のような、準連番的な?名前の付いたファイルだと、つい安心して、P4D モードで(しつこいようですが、作者である私自身が) マークの読み取りを実行してしまう・・・ T_T

その場合、プログラムの仕様だから当然のごとく、読み取りエラーが発生し・・・

このエラー、なに?

・・・みたいな・・・、決まって毎回、「驚きと焦り」の方が先走って脳内を占拠、「エラーの真の原因=ファイル名が連番でないこと」に、作者である自分自身がなかなか気づかない・・・

だから、バカだと、さっき

さすがに最近はそんなことはないが、以前はコレでさんざん悩んだこともあったのです・・・みなさん。

その My マークシートリーダーで、数学の解答用紙を読み取り、別プログラムで処理(受験者に返却する答案や資料を作成)する方向で、現在、既存のプログラムを改良しているのですが・・・

とある休日の朝、シャワーを浴びながら、なぜか、ふと

(そうだ。この際、アレも何とかしておこう)

と、ようやく思い立ったのです。みなさん。

アレとは、もちろん、P4D 使用時に「ファイル名が連番でないとエラーになること」であります。みなさん。

エラーになって(なぜか?毎回のようにその真の原因を忘れ)あわてふためく前に、予め、読み取り指定フォルダ内の拡張子を小文字に変換すると「jpg」or 「jpeg」になるファイルだけ抽出して、そのファイル名の末尾3桁の半角数字が完全に連番であるか・どうかを調べ、もし、問題がある場合はユーザーに通知して、エラーを未然に防止する、そんなプログラムは・・・ ぎゃはは、Delphi さえあれば、わーらっちゃうくらいカンタンに・・・

すぐ出来る・・・ )

そう軽く考えて、朝から始めた「ファイル名が完全に連番であることを確認する関数」作りに、なんと半日以上、費やしてしまったのであります。みなさん。

たぁーくさんサンプルがあると思ってあちこち調べてみたが(私が調べた範囲では)、Web上にその方法を解説している資料も、サンプル・プログラムも、ついに見つけることができなかったのであります。みなさん。

( もしかして・・・ そんな関数作りは「カンタンすぎる」から、サンプルがないのかなー? )

・・・などと思いつつ、でも、実際にそれを書くとなると、誰も話題にしてないって・・・ なんで? いや、それにしちゃ、なんだかんだ、結構・・・ それなりに難しいぞ、と半日ほど、あーでもない・こーでもないをくり返して・・・ なんとか、自分の環境では、期待通りに動作するものが書けたので、もしかしたら、将来、同じことを実現したくて悩んでおられる方の参考になるかも?しれないと思い、ここに書いておくことにしたわけであります。みなさん。

まず、どなたの役にも立たないカモ・・・ですが。とりあえず、核心部分は、次の通り。

implementation

uses
  //  (略)
  System.RegularExpressions,
  Generics.Collections;

  //System.RegularExpressionsはP4D使用時にファイル名が連番であるかどうかを確認するために追加
  //Generics.Collectionsは上と同じ目的でTListを使うために追加

上記ライブラリを2つ、uses しておいて、プログラム全体で使いまわすわけではないので、Formのメンバーにせず、マークシート画像ファイルを読みだす手続き内から呼び出して使う形で次の関数を記述。

procedure TFormMSReader.ProcDataRead(Sender: TObject);
var
  //  (略)
  strMsg:string;
  Ext1, Ext2: string;
  Extension:string;

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  function HasMixedExtensions(const FolderPath: string): Boolean;
  var
    SearchRec: TSearchRec;
    JPGFound, JPEGFound: Boolean;
  begin
    JPGFound := False;
    JPEGFound := False;

    if FindFirst(FolderPath + '\*.jpg', faAnyFile, SearchRec) = 0 then
    begin
      JPGFound := True;
      FindClose(SearchRec);
    end;

    if FindFirst(FolderPath + '\*.jpeg', faAnyFile, SearchRec) = 0 then
    begin
      JPEGFound := True;
      FindClose(SearchRec);
    end;

    Result := JPGFound and JPEGFound;
  end;

  //ファイル名が連番であるかどうか、確認
  function IsSequentialFileNames(const DirPath: String;
    var Extension1, Extension2: String): Boolean;
  var
    FileList: TStringList;
    FileNumbers: TList<Integer>;
    i, j, numStart: Integer;
    tempFileName, fileName, fileNum: string;
  begin

    //Falseで初期化
    Result := False;

    //指定されたディレクトリ内から、指定された拡張子のファイル名を抽出する
    FileList := TStringList.Create;
    FileNumbers := TList<Integer>.Create;

    try

      for j := 0 to 1 do
      begin

        //小文字に変換して拡張子を指定
        case j of
          0:Extension:= LowerCase(Extension1);
          1:Extension:= LowerCase(Extension2);
        end;

        for tempFileName in TDirectory.GetFiles(DirPath, '*' + Extension) do
        begin
          // ファイル名からパスと拡張子を除去
          fileName := TPath.GetFileNameWithoutExtension(tempFileName);
          //数値部分を抽出
          numStart := TRegEx.Match(fileName, '\d+$').Index;
          if numStart <= 0 then
            Exit; // 数値部分がない場合はFalseを返す
          fileNum := Copy(fileName, numStart, Length(fileName) - numStart + 1);
          if TryStrToInt(fileNum, i) then
            FileNumbers.Add(i);
        end;

        //数値部分があるファイルのみ抽出し、比較する
        if FileNumbers.Count > 0 then
        begin
          FileNumbers.Sort;
          for i := 1 to FileNumbers.Count - 1 do
          begin
            if FileNumbers[i] <> FileNumbers[i - 1] + 1 then
              Exit; //連番でない場合はFalseを返す
          end;
          Result := True; //連番である場合はTrueを返す
        end;

      end;

    finally
      FileList.Free;
      FileNumbers.Free;
    end;

  end;

begin

  //文字列型変数 Path に画像ファイルを入れたフォルダへのパスを指定する

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  if HasMixedExtensions(Path) then
  begin
    strMsg:='jpg とjpeg の2種類の拡張子が混在しています。'+#13#10+
      '拡張子はjpg か jpeg のどちらかに統一してください。'+#13#10+
      '処理を中止します。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    Exit;
  end else begin
    //確認用
    //strMsg:='拡張子の混在はありません!';
    //Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

  //画像ファイルを読み込む処理でファイル名が連番であるかどうか、確認する
  try
    Ext1:='jpg';
    Ext2:='jpeg';
    if IsSequentialFileNames(Path, Ext1, Ext2) then
    begin
      //確認用
      //strMsg:='ファイル番号は連番です!';
      //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      //Blog用に実験
      //raise Exception.Create('T_T');
    end else begin
      strMsg:='ファイル番号が連番ではありません!';
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      Exit;
    end;
  except
    on E: Exception do
    begin
      strMsg:='大変です。本物のエラーが発生しました: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;

end;

なんで、こんなイイことに今まで気づかなかったのか???

だから、バカだと、さっき

*(^_^)*♪

2.連鎖の終止符は?

任意のフォルダに連番でないファイル名を付けたマークシート画像を入れてテスト。

五十音的には「連番」と言えるのだろうか?


MS_Reader.exe を起動して、プログラムが期待通りに動作するか、確認。

読み込む画像が入ったフォルダとして、上の「連番じゃない画像フォルダ」を指定し、画像ファイルを読み込もうとすると・・・

やった! やった!!


MS_Reader.exe が、この世に誕生して5年(くらいかな?)。
ようやく、悲しみの連鎖に終止符が打たれたのであります。みなさん。

あとは、正真正銘のエラーが発生しないことを祈るのみであります。みなさん。

こっちのエラーは、マジでやばい >_<

これだけは見たくないのであります。
みなさん。

でも、よく考えたら(考えなくても)
エラーの連鎖を断ち切るためのメッセージが、

エラーメッセージだった

・・・ということは、

連鎖が断ち切れてるどころか、
これは、むしろ、立派な連鎖ではないでしょうか。みなさん。

私は、
ここに、運命を感じたのであります。
みなさん。

僕のじんせいはー *(^_^)*♪

3.まとめ

一部、変数の宣言が足りないカモですが、フォルダを開く処理まで入れた一連のプログラムコードは、次の通りです。

procedure TFormMSReader.ProcDataRead(Sender: TObject);
const
  //ディレクトリ(フォルダ)の存在を確認 -> なければ作成する
  DataPath='ProcData';
var
  iStartFolder: string;
  iDirectories: TArray<string>;
  Path: string;
  SearchPattern: string;
  Option: TSearchOption;
  FileNames:TStringDynArray;
  FileName:string;
  strFN, strCheckFolder:string;
  strMsg:string;
  Ext1, Ext2: string;
  Extension:string;

  //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
  function HasMixedExtensions(const FolderPath: string): Boolean;
  var
    SearchRec: TSearchRec;
    JPGFound, JPEGFound: Boolean;
  begin
    JPGFound := False;
    JPEGFound := False;

    if FindFirst(FolderPath + '\*.jpg', faAnyFile, SearchRec) = 0 then
    begin
      JPGFound := True;
      FindClose(SearchRec);
    end;

    if FindFirst(FolderPath + '\*.jpeg', faAnyFile, SearchRec) = 0 then
    begin
      JPEGFound := True;
      FindClose(SearchRec);
    end;

    Result := JPGFound and JPEGFound;
  end;

  //ファイル名が連番であるかどうか、確認
  function IsSequentialFileNames(const DirPath: String;
    var Extension1, Extension2: String): Boolean;
  var
    FileList: TStringList;
    FileNumbers: TList<Integer>;
    i, j, numStart: Integer;
    tempFileName, fileName, fileNum: string;
  begin

    //Falseで初期化
    Result := False;

    //指定されたディレクトリ内から、指定された拡張子のファイル名を抽出する
    FileList := TStringList.Create;
    FileNumbers := TList<Integer>.Create;

    try

      for j := 0 to 1 do
      begin

        //小文字に変換して拡張子を指定
        case j of
          0:Extension:= LowerCase(Extension1);
          1:Extension:= LowerCase(Extension2);
        end;

        for tempFileName in TDirectory.GetFiles(DirPath, '*' + Extension) do
        begin
          // ファイル名からパスと拡張子を除去
          fileName := TPath.GetFileNameWithoutExtension(tempFileName);

          //数値部分を抽出
          numStart := TRegEx.Match(fileName, '\d+$').Index;
          if numStart <= 0 then
            Exit; // 数値部分がない場合はFalseを返す

          fileNum := Copy(fileName, numStart, Length(fileName) - numStart + 1);
          if TryStrToInt(fileNum, i) then
            FileNumbers.Add(i);

        end;

        //数値部分があるファイルのみ抽出し、比較する
        if FileNumbers.Count > 0 then
        begin
          FileNumbers.Sort;
          for i := 1 to FileNumbers.Count - 1 do
          begin
            if FileNumbers[i] <> FileNumbers[i - 1] + 1 then
              Exit; //連番でない場合はFalseを返す
          end;
          Result := True; //連番である場合はTrueを返す
        end;
      end;
    finally
      FileList.Free;
      FileNumbers.Free;
    end;
  end;

begin

  try

    //読み込むファイルの存在するフォルダを選択

    //Win10のフォルダ選択Dialogを使用する
    iStartFolder := ExpandFileName('.\ProcData');
    if SelectDirectory(iStartFolder, iDirectories,
      [sdHidePinnedPlaces, sdNoDereferenceLinks, sdForceShowHidden,
      sdAllowMultiselect], 'フォルダを選択してください', 'Folder', 'Ok') then
    begin

      //カーソルを待機状態に設定
      Screen.Cursor := crHourGlass;

      //読み込むデータのあるフォルダへのPathを取得
      Path:=iDirectories[0];

      //jpg とjpeg が同一フォルダ内に混在していないことを確認する_20250302追加
      if HasMixedExtensions(Path) then
      begin
        strMsg:='jpg とjpeg の2種類の拡張子が混在しています。'+#13#10+
          '拡張子はjpg か jpeg のどちらかに統一してください。'+#13#10+
          '処理を中止します。';
        Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
        Exit;
      end else begin
        //確認用
        //strMsg:='拡張子の混在はありません!';
        //Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      end;

      //ファイル名が連番であるかどうか、確認
      try
        Ext1:='jpg';
        Ext2:='jpeg';
        if IsSequentialFileNames(Path, Ext1, Ext2) then
        begin
          //確認用
          //strMsg:='ファイル番号は連番です!';
          //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
          //Blog用に実験
          //raise Exception.Create('T_T');
        end else begin
          strMsg:='ファイル番号が連番ではありません!';
          Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
          Exit;
        end;
      except
        on E: Exception do
        begin
          strMsg:='大変です。本物のエラーが発生しました: ' + E.Message;
          Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
        end;
      end;

      // (省略)

    end;
  finally
    Screen.Cursor := crDefault;
  end;

end;

4.お願いとお断り

今回掲載したプログラムは、拡張子が jpg と jpeg の画像が同一フォルダ内に混在していないことを正常動作の前提にしています。この点には十分、ご注意・ご留意いただけますよう、お願い申し上げます。

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

Python4Delphiが突然、実行できなくなった!

これまで自作のデジタル採点プログラムを使った数学のテストの採点処理は、マークシートの読み取り結果を、表計算ソフトのワークシートに出力する方法で、その最終的な処理を行ってきた。

2024年の年末から、2025年の年始にかけての休暇を利用し、これまで書いてきたデジタル採点プログラムの機能を拡張して、表計算ソフトを使わなくても大問6個までの数学のテストであれば、観点別評価にも対応した合計点の計算や、返却用答案の印刷、得点の平均点・最高点・最低点などの情報を含んだ成績一覧表の自動作成と印刷がひとつのプログラムから実行できるように改良。休暇中にある程度のところまで完成させることができた。

だが、休暇が終わると様々な仕事が次から次に舞い込んで、「あともう少しで完成」・・・というところで作業は完全にストップ。そのまま、ほぼ一月半、デジタル採点関連のプログラム作りは休止状態に。

途中、もちろん休日は何日もあったが、スキーに行ったり、徒歩で神社仏閣を巡る旅(行程24km)に参加したり、冬の山に登ったり、いろいろ楽しく遊んでしまって。

2025年2月22日(土)からの3連休で残りの作業を行って、ずっと気になっていたプログラムの改良を完成させるべく、21日(金)の朝、一月半ぶりにデジタル採点のプロジェクトに触ってみたら、あろうことか、実行(F9)すると「 Python4Delphi 関連のファイルが見つからない」エラーが発生。

これまで、思い出せないくらい何度も繰り返してきた、まったく思いもしなかったところでいきなり転ぶ「いつものパターン」に・・・またハマってしまった・・・内心、そんな気がしてならなかったが、今回も何とか自力で解決。もしかしたら、この記事が同じ悲劇に見舞われた方の目にとまることがあるかもしれないと思い、万一にでもお力添えできれば・・・と。これは、そのトラブルの解決方法のメモです。

【もくじ】

1.プロジェクトが実行(F9)できなくなった!
2.Definition.Inc ファイルも見つからない!
3.GetItパッケージマネージャの内容がヘン!
4.まとめ
5.お願いとお断り

1.プロジェクトが実行(F9)できなくなった!

テストの受験者に採点結果を通知する個票を作成するプログラムのプロジェクトファイルを開き、実行(F9)すると、「 Python4Delphi 関連の非ビジュアルコンポーネントが見つからない」エラーが発生。

(1ヶ月前までは普通に動いていたのに・・・。なんで?)

明日からの3連休で、このプログラムを完成させようと思って作業の準備を始めた矢先、まったく予期しないエラーの発生に、ほんとに心が折れそうになる・・・。

試しにマークシートリーダーや手書き答案の採点プログラムの方も確認してみると、実行(F9)できない状況はほぼ同じ。たとえば、マークシートリーダーのプログラムだとコードの先頭でエラーになり、具体的には・・・

unit UnitXXX;

{$WARN UNIT_PLATFORM OFF}
{$WARN SYMBOL_PLATFORM OFF}
{$WARN SYMBOL_DEPRECATED OFF}

{$I Definition.Inc}  // <- ここでエラーになる。

interface

「[dcc32 致命的エラー] UnitXXX.pas(7): F1026 ファイル ‘Definition.Inc’ が見つかりません。」という、今までさんざん実行(F9)して来て、1回も見たことのないメッセージが表示される。

だいたい、この {$I Definition.Inc} の1行が「なぜ、ここにあるのか」すら、思い出せない。(コレ、いつ・なんのために、誰が書いた?)みたいな疑問が浮かび・・・。でも、自分以外の誰かがこれを書くことはあり得ないので、書いたのは間違いなく自分なのだが、いつ・なんのために、書いたのか、それがどうしても思い出せない。

こういう場合に備えて、当たり前のように思うことでも、なるべくコメントとしてソース内に残し、コードを読み直す必要が生じた時に、行っている処理の内容を確実に思い出せるようにプログラムを書いてきたつもりなのだが、頼りとするはずの・・・そのコメントが見当たらない。

( なんでスルーしちゃったのかなぁ )

仕方がないから、Definition.Inc ファイルを検索してみる。が、少なくともプロジェクトファイルを入れたフォルダ内にはない。バックアップの方も検索してみたが、やはりそちらにもない。しばし考えた後、もしかしたら、Delphiと僕のこれまでのすべてを記録してある Tips ファイルの中に Definition.Inc なる文字列があるかと思い、早速、検索してみると、Python4Delphi の library demodll 関連の資料の中にそれを発見。

(やっぱり Python4Delphi 関連のファイルだったんだ・・・)

つまり、今までは、どこかに「Definition.Inc ファイルがあった」から、このエラーは発生しなかった。でも、今は、どこにも「それがない」から(多分)このエラーは起きている。

(なぜ、無くなったんだ?)

とりあえず、Cドライブ全体をくまなく探してみることにした。

2.Definition.Inc ファイルも見つからない!

Windows キーを押しながら、R キーを押して「ファイル名を指定して実行」の入力画面を出し、そこに「cmd」と入力して Enter キーを押し下げ、コマンドプロンプトを表示。次のコマンドを入力する。

dir C:\ /s /b | findstr /i "Definition.Inc"

こうすると、エクスプローラーの検索よりも速く、正確に検索対象ファイルの有無を知ることができるらしい。

もっとも実行する前から結果はわかっていたが。

( やっぱり、ない! )

3.GetItパッケージマネージャの内容がヘン!

Python4Delphi の非ビジュアルコンポーネントは Form の上に見えているが、それらの実際の動作に必要な「ヘッダファイルやライブラリ関連の情報が失われた」ためにエラーが起きているのではないか・・・と。ようやくここで、朧気ながらエラーの原因らしきものが見えてきた。

試しに GetItパッケージマネージャを開いて Python4Delphi のインストール状況を確認してみる。すると、なんと Python4Delphi が「未インストール」状態になっていた!

実際の画面がこちら。

間違いなくインストールしてあったはずなのに・・・


ちなみにインストールされている状態であれば、このように表示される。

これが正しくインストールされた状態


ここまで来れば、出来のよろしくない僕の頭でも十分、状況が理解できた。2025年1月上旬までは、確かに、PCのどこかに存在していたはずの「Definition.Inc」が、いや、それだけでなく Python4Delphi 関連の設定情報のすべてが「ごっそり削除」されるような事件が「つい最近起きた」に違いないと・・・。

僕はすぐに思い出した。10日ほど前のことだったか・・・。確実にオフに設定しておいたはずの OneDrive のデスクトップとの同期がいつの間にか ON に変更されていることに気づき、同期の設定を手動で OFF にして、デスクトップの表示をローカルPCのそれに修正した「あの時」事件は起きたに違いない。

いつ OneDrive のデスクトップとの同期が ON に変更されたのか、それはわからないが、例えば 24H2 へのアップデート時等にそのような形への設定変更が自動的に(と言うか、勝手に)行われ、連動してPython4Delphi 関連の PATH 等の設定情報も OneDrive 側に自動的に修正された(?)

そのことに気づかないまま、僕は PC を使い続け、同期が ON になっていることに気づいた時点で、同期の設定を手動で OFF にした。そこから見えてくることは(これはあくまでも推測だけど)・・・

・24H2 へのアップデート時に、Python4Delphi の一部の設定ファイルやライブラリが OneDrive フォルダ側に移動した?

・Delphi の GetIt パッケージマネージャに記録されている、インストール済みのパッケージのパス情報もOneDrive 側を参照するように自動的に調整された?( or 最初から OneDrive 側だった?)

・OneDrive との同期を手動で OFF に変更したため、Python4Delphi のファイルが移動・消失したのと同じ状態になり、これを Delphi の GetIt パッケージマネージャは「未インストール」と判断した?

とにかく、Python4Delphi が「未インストール」状態になっているのは事実。ならば、再インストールするしかない!

もう迷うことはない。(これで直る)その確信を持ってインストールボタンをクリックする。もちろん、インストールは何の問題もなく順調に終了。

先ほどと同様にコマンドプロンプトを表示し、「Definition.Inc」を検索すると・・・

( あった!)

Definition.Inc をちゃんとCドライブ内に発見!


ローカルの Documents フォルダの中に、今、それがあるということは・・・。

( そうか! いつの間にか OneDrive の Documents を参照する設定になっていたんだ。)

( だから同期を OFF にして、Documents フォルダの内容を削除した際に・・・ PATH も消え・・・ )

エラーの真の原因を理解☆


何でこんなことになったのか? 自問自答して得た結論は・・・

それが、もしかして Windows11 の仕様???

24H2 にアップデートした際に、Documents フォルダやデスクトップへの PATH を確認すべきだった。

いや、後悔しても始まらない。
ここは前向きに、「今後、OS をアップデートした際は、PATH の設定を必ず確認する」ことにしよう!

新しい自分との約束が出来た☆


で、最終確認。

実行(F9)するとエラーが発生したプロジェクトファイルを次から次へ開き、今度は問題なく実行(F9)出来ることを確かめる。

すべて何事もなかったかのように実行(F9)できた!!

なおったー☆

4.まとめ

・OS をアップデートしたら Documents フォルダやデスクトップへの PATH を必ず確認する。

5.お願いとお断り

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

PDFファイルから任意のページを抽出してマージする

ある朝、職場の同僚から「様式(ページ構成)が同じPDFファイルが大量にあるんだが、2ページ目以降は不要なので、1ページ目だけを抽出して、1つのPDFファイルに結合・印刷できるようにしてもらえないか?」との依頼を受けた。

急な依頼だったので、とりあえず任意のフォルダに保存されているPDFファイルの1ページ目だけを抽出するバッチファイルを作って依頼者に渡し、なんとかその場を凌いだが・・・。

以前から Delphi でPDFファイルを操作する方法に関心があり、PDFを画像化するプログラムなどを書いてみたことがあったが、指定ページを抽出する方法や複数のPDFファイルを結合して1つにまとめる方法はわからないままだった。いい機会なのでちゃんと勉強してみることにした。これはその備忘録。

【もくじ】

1.使用するツール
2.指定ページを抽出
3.PDFファイルを結合
4.進捗状況も表示
5.まとめ
  エラー対策1・2を追記(20250211)
  プログラムコード
  PDFtkのインストールの有無を確認する方法を追記(20250218)
6.お願いとお断り

1.使用するツール

PDFファイルの抽出や結合を実行するために使用したのは「PDFtk Server」というコマンドラインから実行するツール。

PDF Labs
https://www.pdflabs.com/tools/pdftk-server/?form=MG0AV3

リンク先ページの中ほどに「Microsoft Windows」というタイトルがあり、「Click to download the PDFtk Server installer for Windows 10 and 11:」という説明の下に「Windows Download」があるので、これをクリックして「pdftk_server-2.02-win-setup.exe」(2025年2月9日現在)をダウンロードしてインストールしておく。

【インストール後、PATHの登録を必ず確認してください】

インストールしたら、システム環境変数のPATHに「pdftk.exe」までのパスが正しく登録されていることを必ず確認する。

「pdftk.exe」までのパスが正しく登録されていることを必ず確認してください。
(図は PDFtk の設定を変更せずにインストールした場合の設定です)


【重要な注意】

インストールした「pdftk.exe」までの PATH をシステム環境変数の PATH に登録せず、「プログラム内で文字列として指定」した場合、ここで紹介するプログラムコードは 動作しません!

2.指定ページを抽出

まず、GUIを作成。


exe のあるフォルダ内に src と dst という名称のフォルダも用意する。


指定ページを抽出する方法は、次の通り。

  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);

implementation

uses
  Winapi.ShellAPI, System.IOUtils;

{$R *.dfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var
  InputDir, OutputDir: string;
  strMsg: string;
begin
  if ComboBox1.Text = '' then
  begin
    strMsg := '抽出するページを指定してください';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONSTOP);
    ComboBox1.SetFocus;
    Exit;
  end;

  InputDir := ExtractFilePath(Application.ExeName) + 'src\';
  OutputDir := ExtractFilePath(Application.ExeName) + 'dst\';

  //出力フォルダが存在しない場合は作成
  if not DirectoryExists(OutputDir) then
  begin
    ForceDirectories(OutputDir);
  end;

  ExtractPDFs(InputDir, OutputDir, StrToInt(ComboBox1.Text));
end;

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  TempPDFs: TStringList;
  Command, TempPDF, ExtractedPDF, LogFile: string;
  strMsg: string;
  PDFtkPath: string;

  //コマンド実行関数(プロセス完了待ち)
  function ExecuteCommand(const Command: string): Boolean;
  var
    StartupInfo: TStartupInfo;
    ProcessInfo: TProcessInformation;
    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    CmdLine: array[0..MAX_PATH] of Char;
  begin
    FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
    StartupInfo.cb := SizeOf(TStartupInfo);
    StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
    StartupInfo.wShowWindow := SW_HIDE;

    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    StrPCopy(CmdLine, Command);
    Result := CreateProcess(nil, CmdLine, nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo);
    if Result then
    begin
      WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
      CloseHandle(ProcessInfo.hProcess);
      CloseHandle(ProcessInfo.hThread);
    end;
  end;

begin

  //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
  //PDFtkPath := '"C:\Program Files (x86)\PDFtk Server\bin\pdftk.exe"';

  //pdftk.exe の PATH は、システム環境変数の PATH で設定する
  PDFtkPath := 'pdftk';

  //エラーがあった場合はLogファイルにエラー内容を出力する
  LogFile := IncludeTrailingPathDelimiter(OutputDir) + 'log.txt';

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) +
            SearchRec.Name, PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
          end;

        until FindNext(SearchRec) <> 0;
      finally
        FindClose(SearchRec);
      end;
    end;

    if TempPDFs.Count > 0 then
    begin
      ExtractedPDF := IncludeTrailingPathDelimiter(OutputDir) + 'filelist.txt';
      TempPDFs.SaveToFile(ExtractedPDF); // ファイルリストを保存
    end;

    //Information
    strMsg := '続けて結合も実行しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      Button2Click(Button1);
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='抽出ページをマージする場合は結合ボタンをクリックしてください';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;

  finally
    TempPDFs.Free;
  end;
end;

3.PDFファイルを結合

ページを抽出後、そのまま結合させることも当然考えたが、処理の確実性を最優先して、別々の手続きに分けて記述することにした。コードは次の通り。

  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);
    procedure MergePDFs;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils;

procedure TForm1.Button2Click(Sender: TObject);
var
  strMsg: string;
begin
  try
    MergePDFs;
    strMsg:='PDFの結合が完了しました!';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  except
    on E: Exception do
    begin
      strMsg:='エラー: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;
end;

procedure TForm1.MergePDFs;
var
  //ShellExecuteを使用
  //InputDir, OutputFile, Command: string;

  //CreateProcessを使用
  InputDir, OutputFile, Command, CmdLine: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
  strMsg: string;
begin

  //ShellExecuteを使用
  {
  InputDir := ExtractFilePath(Application.ExeName)+'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';
  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;
  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('cmd /c pdftk "%s*.pdf" cat output "%s"', [InputDir, OutputFile]);
  //ShellExecuteでpdftkを実行
  ShellExecute(0, 'open', 'cmd.exe', PChar(Command), nil, SW_HIDE);
  }

  //CreateProcessを使用
  InputDir := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';

  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;

  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('pdftk "%s" cat output "%s"', [InputDir + '*.pdf', OutputFile]);

  //コマンドラインを `cmd.exe /c` でラップ
  CmdLine := Format('cmd.exe /c %s', [Command]);

  // `CreateProcess` の設定
  FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
  StartupInfo.cb := SizeOf(TStartupInfo);
  StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
  StartupInfo.wShowWindow := SW_HIDE;

  if CreateProcess(nil, PChar(CmdLine), nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo) then
  begin
    //プロセスが完了するのを待つ
    WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
    //ハンドルを閉じる
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end else begin
    strMsg:='PDFの結合に失敗しました。pdftkが正しくインストールされているか確認してください。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

end;

4.進捗状況も表示

進捗状況も表示できるようにした。プログラムコードは「5.まとめ」の最後に掲載。
(Formに StatusBar と ProgressBar を1つずつ追加)

【実行時の画面】

ProgressBar は、StatusBar に埋め込んで表示する。

5.まとめ

テスト用にファイル名が半角数字「001~100」の100個のPDFファイルを作成して実行。半角数字のファイル名であれば、エラーなく実行できることを確認。
ただし、My環境では、ファイル名に「全角・半角・英数字・記号」が混在しているとエラーになりました。このエラーの発生原因の詳細が判明しましたら、後日追記します。

追記(20250211)

上記エラーの発生原因について調査した結果、PDFtk に渡す PATH に「半角スペース」が混じっているとエラーが発生することが判明。そこで、エラーの発生を防止するため、次の対策1・2を行った。

【対策1】

PDFtk に渡す PATH の文字列をダブルクオートで囲んでから渡すように修正。

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"'+IncludeTrailingPathDelimiter(InputDir)+SearchRec.Name+'"',
          PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
            //省略
          end;

【対策2】

対策1を行った後もエラーが発生。PDFtk に渡す PATH をダブルクオートで囲んでもエラーの発生を防止することはできないようだ。そこで、「長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できるのでは・・・?」と考え、PATH を短縮形式(8.3形式) に変換してから PDFtk に渡すように修正。

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  //略

  //指定フォルダ内にあるPDFファイルの数を取得
  function GetPDFFileCount(const FolderPath: string): Integer;
  var
    Files: TArray<string>;
  begin
    //略
  end;

  function GetShortPath(const LongPath: string): string;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    if GetShortPathNameW(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
      Result := ShortPath
    else
      Result := LongPath; // 失敗時はそのまま
  end;

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  function Is8dot3NameAvailable(const Path: string): Boolean;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    FillChar(ShortPath, SizeOf(ShortPath), 0);
    if GetShortPathNameW(PChar(Path), ShortPath, MAX_PATH) > 0 then
      Result := StrComp(ShortPath, PChar(Path)) <> 0  // 短縮名が取得できたか
    else
      Result := False;
  end;

  //Cドライブの 8.3 名を有効に設定
  procedure Enable8dot3Name(DriveLetter: Char);
  var
    Command: string;
  begin
    //fsutil コマンドで 8.3 名を有効化
    Command := Format('fsutil 8dot3name set %s: 0', [DriveLetter]);
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:=Format('%s: ドライブの 8.3 名を有効にしました。', [DriveLetter]);
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //システム全体の 8.3 名を有効化
  procedure Enable8dot3NameForAllDrives;
  var
    Command: string;
  begin
    //fsutil コマンドでシステム全体の 8.3 名を有効化
    Command := 'fsutil behavior set disable8dot3 0';
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:='すべてのドライブで 8.3 名を有効にしました。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

begin

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  if Is8dot3NameAvailable('C:\Program Files') then
  begin
    if CheckBox1.Checked then
    begin
      strMsg:='8.3 名は有効です';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Windowsでは、長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できます。'+
      '現在、8.3 名(短縮名)は無効です。有効化しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      strMsg:='システム全体で有効化しますか?'+#13#10+#13#10+
        '「いいえ」を選択した場合、Cドライブのみ有効化されます。';
      if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
      begin
        Enable8dot3NameForAllDrives;
        //[はい]が選ばれた時
        strMsg:='8.3 名(短縮名)をシステム全体で有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        //[いいえ]が選ばれた時
        Enable8dot3Name('C');
        strMsg:='Cドライブで、8.3 名(短縮名)を有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='長いファイルパスや特殊文字を含むパスは使用できません。'+
        '注意してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //略

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          //さらに短縮形式(8.3形式) に変換して渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"'+GetShortPath(IncludeTrailingPathDelimiter(InputDir)+SearchRec.Name)+'"',
          PageNum, TempPDF, LogFile]);

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);
            //省略
          end;

ただし、GetShortPathNameW は、ローカルファイルシステムの NTFS/FAT32 に保存されているファイルの短縮名を取得する API であり、UNC パスのような ネットワーク共有上のファイルには対応していない。そこで exe がローカルな環境で実行されていない場合は、Form の表示終了時にユーザーに警告してプログラムを終了するように修正。

Winapi.Shlwapi を uses することで、他の手続きで使用していた StrToInt 関数でエラーが発生。こちらはSystem.SysUtils.StrToInt のように参照先を明示してエラーを回避。

  private
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils,
  Winapi.Shlwapi;

  //Shlwapiはexeの起動PATHの確認に使用
  //ShlwapiにもStrToInt関数があるので StrToInt関数は
  //System.SysUtils.StrToInt のように明示的に使用する

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  function IsUNCPath(const Path: string): Boolean;
  begin
    Result := PathIsUNC(PChar(Path));
  end;

  procedure CheckExePath;
  var
    ExePath: string;
  begin
    ExePath := ExtractFilePath(Application.ExeName);
    if IsUNCPath(ExePath) then
    begin
      strMsg:='EXE はネットワーク上の UNC パスで実行されています!'+#13#10+
        'プログラムが安定動作しない可能性があります。'+#13#10+
        'ローカル環境で実行してください。'+#13#10+
        '安全のため、プログラムを終了します。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      Close;
    end;
  end;
begin
  inherited;
  if Visible then
  begin
    Update;
    //実行PATHをチェック
    CheckExePath;
  end;
end;


GUI も修正。


上記対策を行った結果、(My環境では)半角スペースを含む PATH を PDFtk に渡してもエラーが発生することなく、すべてのファイルから指定ページを抽出・結合することができることを確認。

【プログラムコード】

作成の経過が後から見てわかるよう、古いコードをコメント化して残してあるなど、あちこちに冗長な部分があります。あくまでも参考まで。

unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes, Vcl.Graphics,
  Vcl.Controls, Vcl.Forms, Vcl.Dialogs, Vcl.StdCtrls, Vcl.ComCtrls;

type
  TForm1 = class(TForm)
    Button1: TButton;
    Button2: TButton;
    GroupBox1: TGroupBox;
    Label_01: TLabel;
    Label_02: TLabel;
    Label_04: TLabel;
    ComboBox1: TComboBox;
    Label_03: TLabel;
    Button3: TButton;
    StatusBar1: TStatusBar;
    ProgressBar1: TProgressBar;
    CheckBox1: TCheckBox;
    procedure Button1Click(Sender: TObject);
    procedure Button2Click(Sender: TObject);
    procedure Button3Click(Sender: TObject);
    procedure FormCreate(Sender: TObject);
    procedure FormShow(Sender: TObject);
  private
    procedure ExtractPDFs(const InputDir, OutputDir: string; PageNum: Integer);
    procedure MergePDFs;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;
  public
  end;

var
  Form1: TForm1;

implementation

uses
  Winapi.ShellAPI,
  System.IOUtils,
  Winapi.Shlwapi;

  //Shlwapiはexeの起動PATHの確認に使用
  //ShlwapiにもStrToInt関数があるので StrToInt関数は
  //System.SysUtils.StrToInt のように明示的に使用する

{$R *.dfm}

{ TForm1 }

procedure TForm1.Button1Click(Sender: TObject);
var
  InputDir, OutputDir: string;
  strMsg: string;
begin
  if ComboBox1.Text = '' then
  begin
    strMsg := '抽出するページを指定してください';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONSTOP);
    ComboBox1.SetFocus;
    Exit;
  end;

  InputDir := ExtractFilePath(Application.ExeName) + 'src\';
  OutputDir := ExtractFilePath(Application.ExeName) + 'dst\';

  //出力フォルダが存在しない場合は作成
  if not DirectoryExists(OutputDir) then
  begin
    ForceDirectories(OutputDir);
  end;

  ExtractPDFs(InputDir, OutputDir, System.SysUtils.StrToInt(ComboBox1.Text));

end;

procedure TForm1.Button2Click(Sender: TObject);
var
  strMsg: string;
begin
  try
    MergePDFs;
    strMsg:='PDFの結合が完了しました!';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  except
    on E: Exception do
    begin
      strMsg:='エラー: ' + E.Message;
      Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
    end;
  end;
end;

procedure TForm1.Button3Click(Sender: TObject);
begin
  Close;
end;

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  function IsUNCPath(const Path: string): Boolean;
  begin
    Result := PathIsUNC(PChar(Path));
  end;

  procedure CheckExePath;
  var
    ExePath: string;
  begin
    ExePath := ExtractFilePath(Application.ExeName);
    if IsUNCPath(ExePath) then
    begin
      strMsg:='EXE はネットワーク上の UNC パスで実行されています!'+#13#10+
        'プログラムが安定動作しない可能性があります。'+#13#10+
        'ローカル環境で実行してください。'+#13#10+
        '安全のため、プログラムを終了します。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      Close;
    end else begin
      //何もしない
      //strMsg:='EXE はローカルディスク上で実行されています。';
      //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //ここにやりたいことを書いていく
    //実行PATHをチェック
    CheckExePath;
  end;
end;

procedure TForm1.ExtractPDFs(const InputDir, OutputDir: string;
  PageNum: Integer);
var
  SearchRec: TSearchRec;
  TempPDFs: TStringList;
  Command, TempPDF, ExtractedPDF, LogFile: string;
  strMsg: string;
  PDFtkPath: string;
  intNum, PDFCount: Integer;

  //指定フォルダ内にあるPDFファイルの数を取得
  function GetPDFFileCount(const FolderPath: string): Integer;
  var
    Files: TArray<string>;
  begin
    //Result := 0;
    if not DirectoryExists(FolderPath) then
      raise Exception.CreateFmt('Directory %s does not exist.', [FolderPath]);

    Files := TDirectory.GetFiles(FolderPath, '*.pdf', TSearchOption.soTopDirectoryOnly);
    Result := Length(Files);
  end;

  // コマンド実行関数(プロセス完了待ち)
  function ExecuteCommand(const Command: string): Boolean;
  var
    StartupInfo: TStartupInfo;
    ProcessInfo: TProcessInformation;
    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    CmdLine: array[0..MAX_PATH] of Char;
  begin
    FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
    StartupInfo.cb := SizeOf(TStartupInfo);
    StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
    StartupInfo.wShowWindow := SW_HIDE;

    //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
    StrPCopy(CmdLine, Command);
    Result := CreateProcess(nil, CmdLine, nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo);
    if Result then
    begin
      WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
      CloseHandle(ProcessInfo.hProcess);
      CloseHandle(ProcessInfo.hThread);
    end;
  end;

  function GetShortPath(const LongPath: string): string;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    //if GetShortPathName(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
    if GetShortPathNameW(PChar(LongPath), ShortPath, MAX_PATH) > 0 then
      Result := ShortPath
    else
      Result := LongPath; // 失敗時はそのまま
  end;

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  function Is8dot3NameAvailable(const Path: string): Boolean;
  var
    ShortPath: array[0..MAX_PATH] of Char;
  begin
    FillChar(ShortPath, SizeOf(ShortPath), 0);
    //if GetShortPathName(PChar(Path), ShortPath, MAX_PATH) > 0 then
    if GetShortPathNameW(PChar(Path), ShortPath, MAX_PATH) > 0 then
      Result := StrComp(ShortPath, PChar(Path)) <> 0  // 短縮名が取得できたか
    else
      Result := False;
  end;

  //Cドライブの 8.3 名を有効に設定
  procedure Enable8dot3Name(DriveLetter: Char);
  var
    Command: string;
  begin
    //fsutil コマンドで 8.3 名を有効化
    Command := Format('fsutil 8dot3name set %s: 0', [DriveLetter]);
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:=Format('%s: ドライブの 8.3 名を有効にしました。', [DriveLetter]);
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //システム全体の 8.3 名を有効化
  procedure Enable8dot3NameForAllDrives;
  var
    Command: string;
  begin
    //fsutil コマンドでシステム全体の 8.3 名を有効化
    Command := 'fsutil behavior set disable8dot3 0';
    if ShellExecute(0, 'runas', 'cmd.exe', PChar('/c ' + Command), nil, SW_HIDE) <= 32 then
    begin
      strMsg:='8.3 名の有効化に失敗しました。管理者権限で実行してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end else begin
      strMsg:='すべてのドライブで 8.3 名を有効にしました。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

begin

  //8.3 名(短縮名)が使えるかどうか確認(C:\Program Files でチェック)
  if Is8dot3NameAvailable('C:\Program Files') then
  begin
    if CheckBox1.Checked then
    begin
      strMsg:='8.3 名は有効です';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Windowsでは、長いファイルパスや特殊文字を含むパスを 短縮形式(8.3形式) に変換することで問題を回避できます。'+
      '現在、8.3 名(短縮名)は無効です。有効化しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      strMsg:='システム全体で有効化しますか?'+#13#10+#13#10+
        '「いいえ」を選択した場合、Cドライブのみ有効化されます。';
      if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
      begin
        Enable8dot3NameForAllDrives;
        //[はい]が選ばれた時
        strMsg:='8.3 名(短縮名)をシステム全体で有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        //[いいえ]が選ばれた時
        Enable8dot3Name('C');
        strMsg:='Cドライブで、8.3 名(短縮名)を有効化しました!';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='長いファイルパスや特殊文字を含むパスは使用できません。'+
        '注意してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end;

  //ProgressBar
  ProgressBar1.Visible:=True;
  ProgressBar1.Min:=0;                    //最小値
  ProgressBar1.Position:=0;               //現在の値
  ProgressBar1.Step:=1;                   //増分値

  //カウンタ変数の初期化
  intNum:=0;

  //PDFtkのPATHはシステム環境変数に設定する(文字列で指定しないこと)
  //PDFtkPath := '"C:\Program Files (x86)\PDFtk Server\bin\pdftk.exe"';

  //pdftk.exe の PATH は、システム環境変数の PATH で設定する
  PDFtkPath := 'pdftk';

  //エラーがあった場合はLogファイルにエラー内容を出力する
  LogFile := IncludeTrailingPathDelimiter(OutputDir) + 'log.txt';

  //指定フォルダ内にあるPDFファイルの数を取得
  PDFCount := GetPDFFileCount(InputDir);

  //進捗状況の表示
  StatusBar1.SimpleText:='進捗状況:';
  ProgressBar1.Visible:=True;
  ProgressBar1.Max:=PDFCount;   //最大値

  TempPDFs := TStringList.Create;
  try
    //指定フォルダ内のすべての PDF を検索
    if FindFirst(IncludeTrailingPathDelimiter(InputDir) + '*.pdf', faAnyFile, SearchRec) = 0 then
    begin
      try
        repeat
          TempPDF := IncludeTrailingPathDelimiter(OutputDir) + 'temp_' +
            IntToStr(TempPDFs.Count) + '.pdf';

          //PDFtkをシステム環境変数のPathに正しく指定してある場合
          //PDFファイル名に半角スペースが含まれていると
          //多数のファイルを処理する場合、確実にエラーが発生する
          {
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
            [PDFtkPath, IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name,
            PageNum, TempPDF, LogFile]);
          }

          //PDFtkに渡すPATHをダブルクオートで囲んで渡すように修正
          //さらに短縮形式(8.3形式) に変換して渡すように修正
          Command := Format('cmd.exe /c %s "%s" cat %d output "%s" 2>> "%s"',
          [PDFtkPath, '"' + GetShortPath(IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name) + '"',
          PageNum, TempPDF, LogFile]);

          //ShowMessage('"' + IncludeTrailingPathDelimiter(InputDir) + SearchRec.Name + '"');

          //pdftk を実行して指定ページを抽出
          if ExecuteCommand(Command) then
          begin
            TempPDFs.Add(TempPDF);

            //ProgressBar
            intNum:=intNum+1;  // <- 記述を忘れないこと!
            //値を増やす時
            If ProgressBar1.Position < ProgressBar1.Max Then
            begin
              //目的の値より一つ大きくしてから、目的の値にする
              ProgressBar1.Position:=intNum+1;
              ProgressBar1.Position:=intNum;
            end else begin
              //最大値にする時
              //最大値を1つ増やしてから、元に戻す
              ProgressBar1.Max:=PDFCount+1;
              ProgressBar1.Position:=intNum+1;
              ProgressBar1.Max:=PDFCount;
              ProgressBar1.Position:=intNum;
            end;
            //処理の表示を止めないおまじない
            Application.ProcessMessages;

          end;

        until FindNext(SearchRec) <> 0;
      finally
        FindClose(SearchRec);
      end;
    end;

    //初期化
    ProgressBar1.Position:=0;

    if TempPDFs.Count > 0 then
    begin
      ExtractedPDF := IncludeTrailingPathDelimiter(OutputDir) + 'filelist.txt';
      TempPDFs.SaveToFile(ExtractedPDF); // ファイルリストを保存
    end;

    // Information_YesNo
    strMsg := '続けて結合も実行しますか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      Button2Click(Button1);
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='抽出ページをマージする場合は結合ボタンをクリックしてください';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;

  finally
    TempPDFs.Free;
    //進捗状況の表示
    StatusBar1.SimpleText:='';
    ProgressBar1.Visible:=False;
  end;
end;

procedure TForm1.FormCreate(Sender: TObject);
var
  i, w:integer;
begin
  //StatusBarの設定
  StatusBar1.SimplePanel:=True;
  //プログレスバーの初期化
  with ProgressBar1 do begin
    Parent  :=StatusBar1;
    Top     :=2;  //表示位置の調整
    w:= StatusBar1.Canvas.TextWidth('進捗状況:');
    Left    :=w;
    //Left    :=100;  //表示位置の調整
    Height  :=StatusBar1.Height-2;
    Width := StatusBar1.Width-20;
    Visible :=False;
  end;

  //抽出するページの選択肢を作成
  for i := 1 to 999 do
  begin
    ComboBox1.Items.Add(IntToStr(i));
  end;
end;

procedure TForm1.FormShow(Sender: TObject);
begin
  //Formを画面の中央に表示
  Left:=(Screen.Width-Width) div 2;
  Top:=(Screen.Height-Height) div 2;
end;

procedure TForm1.MergePDFs;
var
  //ShellExecuteを使用
  //InputDir, OutputFile, Command: string;

  //CreateProcessを使用
  InputDir, OutputFile, Command, CmdLine: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
  strMsg: string;
begin

  //ShellExecuteを使用
  {
  InputDir := ExtractFilePath(Application.ExeName)+'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';
  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;
  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('cmd /c pdftk "%s*.pdf" cat output "%s"', [InputDir, OutputFile]);
  //ShellExecuteでpdftkを実行
  ShellExecute(0, 'open', 'cmd.exe', PChar(Command), nil, SW_HIDE);
  }

  //CreateProcessを使用
  InputDir := IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName)) + 'dst\';
  OutputFile := InputDir + 'MergedOutput.pdf';

  //既存のファイルがあれば削除する
  if FileExists(OutputFile) then
  begin
    //削除
    DeleteFile(OutputFile);
  end;

  //pdftkコマンドの構築(すべてのPDFを結合)
  Command := Format('pdftk "%s" cat output "%s"', [InputDir + '*.pdf', OutputFile]);

  //コマンドラインを `cmd.exe /c` でラップ
  CmdLine := Format('cmd.exe /c %s', [Command]);

  // `CreateProcess` の設定
  FillChar(StartupInfo, SizeOf(TStartupInfo), 0);
  StartupInfo.cb := SizeOf(TStartupInfo);
  StartupInfo.dwFlags := STARTF_USESHOWWINDOW;
  StartupInfo.wShowWindow := SW_HIDE;

  if CreateProcess(nil, PChar(CmdLine), nil, nil, False, CREATE_NO_WINDOW, nil, nil, StartupInfo, ProcessInfo) then
  begin
    //プロセスが完了するのを待つ
    WaitForSingleObject(ProcessInfo.hProcess, INFINITE);
    //ハンドルを閉じる
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end else begin
    strMsg:='PDFの結合に失敗しました。pdftkが正しくインストールされているか確認してください。';
    Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
  end;

end;

end.

追記(20250218)

システム環境変数に PDFtk への PATH が正しく設定されているか、どうかを調べる他に、もう一つ、PDFtk がインストールされているか、どうかを確認する方法も調べてみた。

コマンド・プロンプトを起動して、下記のように「pdftk –version」と入力し、Enter キーを押し下げると、PDFtkがインストールされていれば、次のように応答が返る。


このことを確認しておいて、プログラムを書き、実行するとエラーが発生。当初、なぜエラーになる(IsPDFtkInstalled 関数が False を返す)のか、わからなかったが、出力を確認したところ、ようやく原因が判明。出力は次の通り。

出力が「文字化け」している・・・


シェルの出力を UTF-8 として処理するよう、プログラムを修正。

AStream := TStringStream.Create('', TEncoding.UTF8);

で、ここに出力して・・・

AStream.WriteBuffer(ABuffer, ARead);

さらに StringList に入れて「小文字」にして、出力結果に ‘pdftk’ の文字列が含まれているか、どうかを確認。

AOutput := TStringList.Create;
AOutput.Text := AStream.DataString;

(略)

if Pos('pdftk', LowerCase(AOutput.Text)) > 0 then
begin
  Result := True;
end;

期待通りに動作することを、メッセージを表示して確認(確認後、このメッセージ表示部分はコメント化し、実際に実行する際はインストールされていない場合のみ、メッセージを表示する仕様とした)。

全体のコードは、次の通り。

  private
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

procedure TForm1.CMShowingChanged(var Msg: TMessage);
var
  strMsg:string;

  //PDFtkのインストールの有無を確認
  function IsPDFtkInstalled: Boolean;
  var
    AStartupInfo: TStartupInfo;
    AProcessInfo: TProcessInformation;
    ASecurityAttributes: TSecurityAttributes;
    ABuffer: array[0..1023] of Byte;
    ARead: Cardinal;
    AStdOutPipeRead, AStdOutPipeWrite: THandle;
    ACommand: String;
    AOutput: TStringList;
    AStream: TStringStream;
  begin
    Result := False;
    AOutput := TStringList.Create;
    AStream := TStringStream.Create('', TEncoding.UTF8);
    try
      FillChar(ASecurityAttributes, SizeOf(ASecurityAttributes), 0);
      ASecurityAttributes.nLength := SizeOf(ASecurityAttributes);
      ASecurityAttributes.bInheritHandle := True;

      CreatePipe(AStdOutPipeRead, AStdOutPipeWrite, @ASecurityAttributes, 0);
      try
        FillChar(AStartupInfo, SizeOf(AStartupInfo), 0);
        AStartupInfo.cb := SizeOf(AStartupInfo);
        AStartupInfo.hStdOutput := AStdOutPipeWrite;
        AStartupInfo.hStdError := AStdOutPipeWrite;
        AStartupInfo.dwFlags := STARTF_USESTDHANDLES or STARTF_USESHOWWINDOW;
        AStartupInfo.wShowWindow := SW_HIDE;

        ACommand := 'pdftk --version';
        if CreateProcess(nil, PChar('cmd.exe /C ' + ACommand), nil, nil, True, CREATE_NO_WINDOW, nil, nil, AStartupInfo, AProcessInfo) then
        try
          CloseHandle(AStdOutPipeWrite);
          while ReadFile(AStdOutPipeRead, ABuffer, SizeOf(ABuffer), ARead, nil) do
          begin
            if ARead = 0 then Break;
            AStream.WriteBuffer(ABuffer, ARead);
          end;
          AOutput.Text := AStream.DataString;
          WaitForSingleObject(AProcessInfo.hProcess, INFINITE);
        finally
          CloseHandle(AProcessInfo.hProcess);
          CloseHandle(AProcessInfo.hThread);
        end;
      finally
        CloseHandle(AStdOutPipeRead);
      end;

      if AOutput.Count > 0 then
      begin
        //確認用
        //strMsg:='PDFtk output: ' + AOutput.Text;
        //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
        if Pos('pdftk', LowerCase(AOutput.Text)) > 0 then
        begin
          Result := True;
        end;
      end else begin
        //No output from PDFtk command.
        strMsg:='PDFtk からの出力がありません。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    finally
      AOutput.Free;
      AStream.Free;
    end;
  end;

begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //PDFtkのインストールの有無を確認
    try
      if IsPDFtkInstalled then
      begin
        //確認用
        //strMsg:='PDFtk はインストールされています。';
        //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        strMsg:='PDFtk はインストールされていません。'+#13#10+#13#10+
          'https://www.pdflabs.com/tools/pdftk-the-pdf-toolkit/'+#13#10+
          '上記Webサイトからダウンロード&インストールしてください。'+#13#10+#13#10+
          'プログラムを終了します。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
        Close;
      end;
    except
      on E: Exception do begin
        strMsg:='エラー: ' + E.Message;
        Application.MessageBox(PChar(strMsg), PChar('エラー'), MB_ICONERROR);
      end;
    end;
  end;

end;


【注意のお願い】

追記(20250218)の「PDFtk のインストールを確認するプログラムコード」は、上記の「全体のプログラムコード」には含まれておりません。ご注意願います。

6.お願いとお断り

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

StringGridの自動入力・セルの色分け

組み合わせ採点を行うプログラムを書いた際、StringGridの列に連番を自動入力したり、セルの値が同じ範囲を自動的に色分け(背景色を変更)するプログラムを書いた。これは、その備忘録。

※ Grid の列への連番自動入力他、前回の記事と重複する部分があります。ご容赦ください。

【もくじ】

1.StringGridの基本設定(VCL)
2.列に連番を自動入力
3.連番であるかチェック
4.セルの値が同じ範囲を自動判別して背景色を変更
5.同じ値のセル範囲を自動取得してフラグ化
6.お願いとお断り

1.StringGridの基本設定(VCL)

Form に StringGrid をひとつだけ用意して、次のコードを準備する。


コードは、次の通り。

unit Unit1;

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, 
  System.Classes, Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, 
  Vcl.Grids, Vcl.StdCtrls;

type
  TForm1 = class(TForm)
    StringGrid1: TStringGrid;
    CheckBox1: TCheckBox;
    procedure FormCreate(Sender: TObject);
    procedure StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
      var CanSelect: Boolean);
    procedure StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
      Rect: TRect; State: TGridDrawState);
    procedure StringGrid1KeyPress(Sender: TObject; var Key: Char);
    procedure StringGrid1SetEditText(Sender: TObject; ACol, ARow: LongInt;
      const Value: string);
  private
    { Private 宣言 }
    //StringGridの列数を設定 -> FormCreate時に設定する
    StrGrid1ColCount: Integer;
    //Formの表示終了イベントを取得
    procedure CMShowingChanged(var Msg:TMessage); message CM_SHOWINGCHANGED;

  public
    { Public 宣言 }
  end;

var
  Form1: TForm1;

implementation

{$R *.dfm}

procedure TForm1.CMShowingChanged(var Msg: TMessage);
begin
  inherited; {通常の CMShowingChagenedをまず実行}
  if Visible then
  begin
    Update; {完全に描画}
    //セットフォーカス
    StringGrid1.Col:=1;
    StringGrid1.Row:=1;
    StringGrid1.SetFocus;
    //セルの編集を開始(ユーザーのクリックを待つ場合はコメント化する)
    StringGrid1.Options := StringGrid1.Options + [goEditing];
    //カーソルが見えるようにする
    StringGrid1.EditorMode:=True;
  end;
end;

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

  //列数
  StrGrid1ColCount:=3;
  StringGrid1.ColCount:=StrGrid1ColCount;

  //FixedCols & FixedRows(固定列と固定行)を設定
  StringGrid1.FixedCols:=1;
  StringGrid1.FixedRows:=1;

  StringGrid1.Rows[0].CommaText:='番号,連番,TF';

  //FixedRows(固定行)に値をセット
  for i:= 1 to StringGrid1.RowCount do
  begin
    StringGrid1.Rows[i].Append(IntToStr(i));
  end;

end;

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
  Rect: TRect; State: TGridDrawState);
begin
  if StringGrid1.Cells[ACol,ARow]<>'' then
  begin
    //背景色を白に設定
    StringGrid1.Canvas.Brush.Color:=clWhite;
    //セルを塗りつぶす
    StringGrid1.Canvas.FillRect(Rect);
    //テキストを表示(中央寄せ)
    DrawText(StringGrid1.Canvas.Handle,
      PChar(StringGrid1.Cells[ACol,ARow]),
      //[+1]は数値描画位置の調整のため
      Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);
  end;
end;

procedure TForm1.StringGrid1KeyPress(Sender: TObject; var Key: Char);
begin
  //[Enter]キーでコントロールを移動
  if Ord(Key)=VK_RETURN then
  begin
    if ActiveControl is TStringGrid then
    begin
      if TStringGrid(ActiveControl).EditorMode then
      begin
        //VK_TABではカーソルがレコードの項目を右へ移動。
        //ActiveControl.Perform(WM_KEYDOWN,VK_TAB,0);
        //VK_DOWNにすると同じ項目の次のレコードへ移動。
        ActiveControl.Perform(WM_KEYDOWN,VK_DOWN,0);
        Key:=#0;
      end;
    end else begin
      SelectNext(ActiveControl,True,True);
      Key:=#0;
    end;
  end;
end;

列の編集の可否を制御したい場合は、以下のコードで実現可能。

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
  var CanSelect: Boolean);
begin
  //列の編集の可否
  if (ACol=StrGrid1ColCount-1) then
  begin
    //セルの編集は不可
    TStringGrid(Sender).Options:=TStringGrid(Sender).Options-[goEditing];
  end else begin
    //セルは編集可能
    TStringGrid(Sender).Options:=TStringGrid(Sender).Options+[goEditing];
  end;
end;

2.列に連番を自動入力

「常に自動入力する」設定だと、同じ値の連続入力を許可して、それを何かのフラグ(例えば組み合わせ採点の組み合わせ設問設定フラグ)として利用するような場合、後で入力値の修正が必要になったとき大変なことになるので、より実用的にするなら CheckBox などを用意して、「チェックあり」の場合のみ動作するように設定する等の工夫が必須(だと思う)。

次は、チェックボックスのチェックの有無で動作をON・OFFする場合の例。

Form に CheckBox を1つ追加


コードは、次の通り。

procedure TForm1.StringGrid1SetEditText(Sender: TObject; ACol, ARow: LongInt;
  const Value: string);
var
  NewValue: Integer;

  procedure UpdateColumnData(StartRow, NewValue: Integer);
  var
  i: Integer;
  begin
    for i := StartRow + 1 to StringGrid1.RowCount - 1 do
      StringGrid1.Cells[StrGrid1ColCount-2, i] := IntToStr(NewValue + 1);
  end;

begin
  //チェックボックスがチェックされていたら
  if CheckBox1.Checked then
  begin
    //行を自動入力
    if ACol = StrGrid1ColCount-2 then
    begin
      if TryStrToInt(Value, NewValue) then
      begin
        UpdateColumnData(ARow, NewValue);
      end;
    end;
  end;
end;


チェックボックスにチェックした際、Grid コントロールにセットフォーカスさせたければ、次のコードも追加する。

procedure TForm1.CheckBox1Click(Sender: TObject);
begin
  if CheckBox1.Checked then
  begin
    //セットフォーカス
    StringGrid1.Col:=1;
    StringGrid1.Row:=1;
    StringGrid1.SetFocus;
    //セルの編集を開始(ユーザーのクリックを待つ場合はコメント化する)
    StringGrid1.Options := StringGrid1.Options + [goEditing];
    //カーソルが見えるようにする
    StringGrid1.EditorMode:=True;
  end;
end;

実行(F9)時の動作は、次の通り(Enter キーを数回、押し下げ後の状態)。


CheckBox にチェックを入れて、1行1列目のセルをクリックしてEnterキーを押し下げる度にフォーカスが下へ移動し、連番が自動入力される。

同じ番号を入力したい場合は、手動で入力してEnterキーを押し下げ。
※ 入力値を組み合わせ採点を実行するフラグとして利用したかったため、このような仕様とした。

この例では、5行目の「5」は自動入力されるので、
6、7行目の「5」を手入力する。

3.連番であるかチェック

同じ値の繰り返しを許可した上で、入力された値が連番になっているかをチェックする。
FormにButtonを1つ追加して、ボタンをクリックした際にチェックを実行。

Form に Button を1つ追加。


コードは次の通り。

procedure TForm1.Button1Click(Sender: TObject);
var
  ColumnValues: TStringList;
  i: Integer;
  ErrorRows: TStringList;
  ErrorMessage: string;

  function IsSequential(Column: TStrings; out ErrorRows: TStringList): Boolean;
  var
    k, CurrentValue, ExpectedValue: Integer;
  begin
    Result := True; //初期状態で連番と仮定
    ErrorRows.Clear;

    if Column.Count = 0 then
      Exit; //空の場合は連番とみなす

    CurrentValue := StrToInt(Column[0]);
    for k := 1 to Column.Count - 1 do
    begin
      //現在の値が同じであれば次の行へ
      if StrToInt(Column[k]) = CurrentValue then
      begin
        Continue;
      end else begin
        //現在の値が変わった場合、期待される次の値は1増加
        ExpectedValue := CurrentValue + 1;
        //期待される次の値と一致しなければ連番ではない(同じ値のくり返しは許可する)
        //if StrToInt(Column[k]) <> ExpectedValue then
        if (StrToInt(Column[k]) = CurrentValue) or
          (StrToInt(Column[k]) <> ExpectedValue) then
        begin
          Result := False;
          //エラーの行番号を追加(1から始まるインデックスのため +1)
          ErrorRows.Add(IntToStr(k + 1));
          Exit;
        end else begin
          CurrentValue := ExpectedValue;
        end;
      end;
    end;
  end;

begin
  //連番になっていることを確認
  ColumnValues := TStringList.Create;
  ErrorRows := TStringList.Create;
  try
    //StringGridの第1列(インデックス0)を取得
    for i := 1 to StringGrid1.RowCount - 1 do
    begin
      ColumnValues.Add(StringGrid1.Cells[1, i]);
    end;
    if IsSequential(ColumnValues, ErrorRows) then
    begin
      ShowMessage('連番です');  //確認用
    end else begin
      //連番でない行がある場合のメッセージ
      ErrorMessage := ErrorRows.CommaText + ' 行目が連番ではありません!';
      Application.MessageBox(PChar(ErrorMessage), PChar('エラー'), MB_ICONSTOP);
      StringGrid1.Col:=1;
      StringGrid1.Row:=StrToInt(ErrorRows.CommaText);
      StringGrid1.SetFocus;
      Exit;
    end;
  finally
    ColumnValues.Free;
    ErrorRows.Free;
  end;
end;

実行(F9)して、動作テスト。

10行目にわざと連番ではない値を入力して動作テスト


同じ値の繰り返しは許可するようにコーディングしたので、次のような場合は連番と判断する。

        //期待される次の値と一致しなければ連番ではない(同じ値のくり返しは許可する)
        //if StrToInt(Column[k]) <> ExpectedValue then
        if (StrToInt(Column[k]) = CurrentValue) or
          (StrToInt(Column[k]) <> ExpectedValue) then
        begin

4.セルの値が同じ範囲を自動判別して背景色を変更

業務用のプログラムでは、上の図のように同じ値が繰り返し入力されているセルがたやすく見分けられるように工夫した方が好ましいと考え、セルの値が同じ範囲を自動判別して背景色を変更するコードを追加する。

まず、uses に System.Generics.Collections を動的配列要素のSortのために追加。

implementation

uses
  System.Generics.Collections;

{$R *.dfm}


次に、Gridコントロールの OnDrawCell 手続きに以下のコードを記述。

procedure TForm1.StringGrid1DrawCell(Sender: TObject; ACol, ARow: LongInt;
  Rect: TRect; State: TGridDrawState);
var
  Value: String;
  CellColor: TColor;
  ColorMap: TDictionary<String, TColor>;

  function GetLightColor(BaseColor: TColor): TColor;
  var
    R, G, B: Byte;
    pct: Double;  //パーセントを指定する変数
  begin
    // RGB値を取得
    R := GetRValue(ColorToRGB(BaseColor));
    G := GetGValue(ColorToRGB(BaseColor));
    B := GetBValue(ColorToRGB(BaseColor));

    //薄い色に調整(50%白に近づける場合)
    //R := (R + 255) div 2;
    //G := (G + 255) div 2;
    //B := (B + 255) div 2;

    //80%白に近づける場合
    //元のRGB値を20%だけ残し、残りの80%を白(255, 255, 255)に近づける
    //R := Round(R * 0.2 + 255 * 0.8);
    //G := Round(G * 0.2 + 255 * 0.8);
    //B := Round(B * 0.2 + 255 * 0.8);

    //薄い色に調整
    pct:=StrToFloat('0.' + ComboBox1.Text);
    R := Round(R * (1-pct) + 255 * pct);
    G := Round(G * (1-pct) + 255 * pct);
    B := Round(B * (1-pct) + 255 * pct);

    Result := RGB(R, G, B);
  end;

  procedure AssignColorsToValues(ACol: Integer);
  var
    i: Integer;
    Value: String;
    BaseColors: TArray<TColor>;  //動的配列として宣言(解放はDelphiにまかせる)
    ColorIndex: Integer;
  begin
    ColorMap.Clear;
    ColorIndex := 0;

    BaseColors:=[clRed, clGreen, clBlue, clYellow, clAqua, clFuchsia];

    for i := 1 to StringGrid1.RowCount - 1 do
    begin
      Value := StringGrid1.Cells[ACol, i];
      if not ColorMap.ContainsKey(Value) then
      begin
        //色を薄く調整したものを登録
        ColorMap.Add(Value, GetLightColor(BaseColors[ColorIndex mod Length(BaseColors)]));
        Inc(ColorIndex);
      end;
    end;
  end;

begin

  //前掲のコードは、Gridの初期化も兼ねる
  if StringGrid1.Cells[ACol,ARow]<>'' then
  begin
    //背景色を白に設定
    StringGrid1.Canvas.Brush.Color:=clWhite;
    //セルを塗りつぶす
    StringGrid1.Canvas.FillRect(Rect);
    //テキストを表示(中央寄せ)
    DrawText(StringGrid1.Canvas.Handle,
      PChar(StringGrid1.Cells[ACol,ARow]),
      //[+1]は数値描画位置の調整のため
      Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
      DT_CENTER or DT_VCENTER or DT_SINGLELINE);
  end;

  if ARow = 0 then Exit; //ヘッダー行はスキップ

  ColorMap := TDictionary<String, TColor>.Create;

  //色分け対象列を指定
  AssignColorsToValues(1); //ColorMapをCreateしてから呼び出すこと!

  try
    if ACol = 1 then //対象列をチェック
    begin
      Value := StringGrid1.Cells[ACol, ARow];
      if ColorMap.TryGetValue(Value, CellColor) then
      begin
        StringGrid1.Canvas.Brush.Color := CellColor;
        StringGrid1.Canvas.FillRect(Rect);
        //テキストを表示(中央寄せ)_[+1]は数値描画位置の調整のため
        DrawText(StringGrid1.Canvas.Handle,
          PChar(StringGrid1.Cells[ACol,ARow]),
          Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
          DT_CENTER or DT_VCENTER or DT_SINGLELINE);
      end;
    end else begin
      StringGrid1.Canvas.FillRect(Rect);
      //テキストを表示(中央寄せ)_[+1]は数値描画位置の調整のため
      DrawText(StringGrid1.Canvas.Handle,
        PChar(StringGrid1.Cells[ACol,ARow]),
        Length(StringGrid1.Cells[ACol,ARow])+1,Rect,
        DT_CENTER or DT_VCENTER or DT_SINGLELINE);
    end;
  finally
    ColorMap.Free;
  end;

end;


さらに、FormCreate 手続きで ComboBox の選択肢の準備と初期化を行うように設定。

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

  //前掲の通りなので略

  //色の濃さを調節
  for i := 1 to 99 do
  begin
    ComboBox1.Items.Add(IntToStr(i));
  end;

  //初期値を設定
  ComboBox1.Text:='50';

end;


連番で実行(F9)した場合、


同じ値を適当に入力してみた場合、

5.同じ値のセル範囲を自動取得してフラグ化

1.基本設定の最後で「列の編集の可否を制御したい場合は、以下のコードで実現可能」としたのは、実はTFフィールドをフラグとして利用したかったため。

具体的に何がしたかったかと言うと、TF列の任意のセルをクリックしたとき、その左の連番列の同じ値が入力されているセルを自動判別して、TF列の同じセル範囲にクリックで「1」を、スペース押し下げで「0」を自動(切り替え)入力するトグル的操作の実現。

実用上の目的は、連番列で同じ番号が入力されている(=同じ背景色)セルを処理上はセットにして扱うが、TF列に設定されている値が「1」であるセルと、「0」であるセルとで行う処理の内容を分けたいというもの。

つまり、連番列で同じ番号が入力されているセルは「組み合わせ」て採点し、さらにTF列の値が「1」であれば「順不同」で採点を行いたい場合のフラグとして利用できるようにしたかった。

そのための布石として、TF列の自由な編集を不可に設定。

procedure TForm1.StringGrid1SelectCell(Sender: TObject; ACol, ARow: LongInt;
var CanSelect: Boolean);
begin
//列の編集の可否
if (ACol=StrGrid1ColCount-1) then
begin
//セルの編集は不可
TStringGrid(Sender).Options:=TStringGrid(Sender).Options-[goEditing];
end else begin
//セルは編集可能
TStringGrid(Sender).Options:=TStringGrid(Sender).Options+[goEditing];
end;
end;

共通利用する手続きとして、次の手続きを追加(Shift+Ctrl+C で TForm1 のメンバとして作成)。

  private
    { Private 宣言 }

    //状態の切り替え
    procedure ToggleSGCell(ACol, ARow: Integer);
    procedure UpdateColumnData(Value: Integer; IsChecked: Boolean);


ToggleSGCell 手続きのコードは、次の通り。

procedure TForm1.ToggleSGCell(ACol, ARow: Integer);
begin
  //現在の値を切り替え
  if StringGrid1.Cells[ACol, ARow] = '1' then
    StringGrid1.Cells[ACol, ARow] := '0'
  else
    StringGrid1.Cells[ACol, ARow] := '1';

  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;


UpdateColumnData のコードは、次の通り。

procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin

  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;

  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;

  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;

プログラムの仕様として、TF列の任意のセルをクリックしたら、連番列の値を調査して同じ値が連続して入力されているセル全てに「1」を入力したいので、OnMouseDown 手続きに次のコードを記述。

procedure TForm1.StringGrid1MouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Col, Row: Integer;
begin
  //マウスクリックでセルの0と1を切り替え
  StringGrid1.MouseToCell(X, Y, Col, Row);
  if (Col = StrGrid1ColCount-1) and (Row > 0) then
    ToggleSGCell(Col, Row);
end;

で、OnMouseUp イベントで連番列の値を判定。同じ値の入力されているセル範囲を取得して、TF列の同じ行に「1」を自動入力する。

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したときのイベント
  StringGrid1.MouseToCell(X, Y, ACol, ARow);

  //if (ACol = StrGrid1ColCount-1) and (ARow >= 0) then
  //0行目(FixedRow)では動作しないように設定
  if (ACol = StrGrid1ColCount-1) and (ARow > 0) then
    //UpdateColumnData(ARow);
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;

TF列の任意のセルをクリックして、スペースキー押し下げで入力値を「0」に切り替える。

procedure TForm1.StringGrid1KeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  //スペースキーで0と1を切り替え
  if (StringGrid1.Col = StrGrid1ColCount-1) and (StringGrid1.Row > 0) and (Key = VK_SPACE) then
  begin
    ToggleSGCell(StringGrid1.Col, StringGrid1.Row);
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, StringGrid1.Row]), False);
    Key := 0;
  end;
end;

TF列をゼロで初期化するため、FormCreate 手続きの既存のコードに次のコードを追加。

  //FixedRows(固定行)に値をセット
  for i:= 1 to StringGrid1.RowCount do
  begin
    StringGrid1.Rows[i].Append(IntToStr(i));
    //TF列をゼロで初期化
    StringGrid1.Cells[2,i] := '0';
  end;


実行(F9)の動作は、次の通り。

TF列の任意のセル(5行目)をクリックした場合。


同じセルをクリックして選択後、スペースキー押し下げでゼロに切り替え。

6.お願いとお断り

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

組み合わせ採点を実現したい!

2024年11月27日(水)、ある高名な化学者の講演を聴いた。「研究を続けてきた中で、最も困難であったことは何か?」という問いに対し、彼は「実験の99%が失敗であったことだ。」と即答。

その言葉を反芻するうちに、表計算ソフトを使わなければ自分には実現不可能と信じ、
チャレンジする前からあきらめていた「組み合わせ採点」のことを思い出した。

「方向性さえ間違えなければ、失敗の山を築こうとも、いつか必ず成功する。大切なのは、その成功の瞬間を見逃さないことだ。」

僕は、化学者の言葉を、心から信じようと、思った。

表計算ソフトに頼らない「組み合わせ採点」。
Object Pascal だけで書く「組み合わせ採点」。
もしかしたら、僕にも書けるかもしれない・・・と、自分史上、初めて、本気で、そう思えた。

【もくじ】

1.情報処理手順
2.実装
(1)Gridコントロール
(2)組み合わせ採点
(3)順不同採点
3.お知らせ
4.お願いとお断り

1.情報処理手順

まず、最初に「組み合わせ採点」なるものの定義。

例えば、選択肢数が1設問につき8個あるマークシートを考える。そのとき、次のように

    設問1 設問2 設問3
マーク  1   2   3
正 解  1   2   3

設問1~3のマークと正解が完全に一致した場合に「正解」とする採点方法だ。

また、可能であれば、「組み合わせ & 順不同採点」も実現したい。それはつまり、

    設問1 設問2 設問3
マーク  1   2   3
マーク  1   3   2
マーク  2   1   3
マーク  2   3   1
マーク  3   1   2
マーク  3   2   1

このすべてが正解という採点方法、すなわち、解答の順番は不問にして、とにかく設問1~3の解答として1・2・3のいずれかがマークされていればよいというもの(実際の試験では、これまでは「正しいものを昇順に3つ選べ」というような問題文にしたり、正しい語句等を3つ組み合わせた解答の選択肢を用意する必要があったが、これが単に「正しいものを3つ選べ」という表現でよくなる)。

また、組み合わせ採点が設定可能な設問は、必ず連続で並んでいるものとする。
つまり、次のような設定は最初から考えない(設定不可)。

    設問1 設問2 設問3 設問4 設問5
マーク  2       3       4
正 解  2       3       4

「組み合わせ採点」を英語では、次のように表現するようだ。

Combination Matching System -> 組み合わせの「一致性」に基づく評価。
Combination Marking System -> 採点(marking)を強調。教育や試験で使える表現。
Composite Marking System -> 要素を統合してスコアを出す評価システム。

いずれも頭文字を組み合わせると CMS になる。
自分的には、マークシートの採点だから Combination Marking System かな?

それから「順不同」を英語で言うと、No Particular Order だから、こちらは略して NPO だ。

これから書くプログラムでは、この略称でそれぞれの採点方法を表現することにする。
(・・・と勝手に決める)

はたしてどうやったら組み合わせ採点のアルゴリズムを一般化できるか、考える。マークシートリーダーのプログラムを書いたときにも、ちらっと組み合わせ採点のことは脳裏をかすめたが、すぐに表計算ソフトを使ってなんとかすればいいやって・・・。

あのときは表計算ソフトのセルを Delphi で操作するプログラムを書いて、それで誤魔化してしまったんだ。表計算ソフトのファイルにADOで接続して、セルを結合させ、プログラムで作成した式を書き込んで、組み合わせ採点を行った。だから、ワークシートを改変されると、もう、それだけで動作しなかった。

純粋に Delphi だけで、組み合わせ採点を実現するのは、少なくても自分には無理だ・・・と、あのころの僕は、信じて疑わなかったから。

それなのに、なぜ、今は「それが出来る」と考えて、その実現に向かって歩こうとしているのか。

僕は以前より、よくなれたんだろうか・・・

それは おそらく 僕が決めることでは、ないだろう。

自作のプログラムの採点設定画面を見つめて、まず思ったことは、例えば設問1~3を組み合わせ採点するとしたら配点は、3つある配点入力セルの「いずれか1つ」に入力し、残りのセルにはゼロを入れてこれを採点結果印刷行などのフラグとして使う案(下図参照)。

自作の採点結果通知個票作成プログラムの画面

組み合わせ採点・順不同採点は出来ませんが、1問1答形式であれば使用できる(?)マークシートリーダーと手書き答案の採点プログラム、及び採点結果を受験者に通知する個票を作成するプログラムをセットにした zip ファイルを次のリンク先で無料で公開しています。


つまり、配点が「ゼロでない」場合のみ、採点結果通知個票に正解なら○(マル)、そうでなければ×(バツ)を印刷すればいい。

ここで気がついたのだけれど、組み合わせて採点して正解にする以上、観点別評価の区分はどうしても同じにする必要があるということ。これを設問毎に別々に設定可能とすると相当やっかいなことになりそうだ。

約束ごとをさらに1つ増やそう。
組み合わせ採点を設定した設問の観点別評価は観点1か、2のいずれかに統一する。

で、この他に、どの設問を組み合わせ採点とするのか、やはり明示的に示せた方がよい。グリッドコントロールの列を増やし、組み合わせ採点を行う設問には同じ番号を入力してもらうのはどうか?

そうすれば組み合わせ採点箇所は一目瞭然だ。・・・てか、組み合わせ採点をする箇所は何設問分あろうと採点箇所1個としてとらえ、組み合わせ採点をしない箇所も含めて、連番・昇順の通し番号を割り当て、プログラム実行時にその数だけ動的に配列を生成して、そこにマークされた選択肢の番号や正解の選択肢の番号をまとめて入れて・・・

「マーク配列」と「正解配列」を比較して、完全に一致したときのみ正解にすれば・・・

組み合わせ採点を実現できそうだ。

さらに、順不同採点を実行する場合は、例えば、それを実行しないフラグをゼロ、実行するフラグを1として、組合せ採点番号と一緒にこちらも明示的に設定してもらう。

実行時に、組み合わせ採点が設定されていて、かつ順不同採点の実行フラグが1なら、その組合せ採点番号のマーク配列と正解配列の要素をそれぞれ昇順ソート(もちろん、降順でもかまわないが)して比較・・・完全一致した場合だけ正解とすれば・・・

順不同採点も同時に実現できそうだ。

そう思って作成したのが、こちらのグリッドコントロール。

CMSフィールドが組み合わせ採点の番号、NPOフィールドが順不同採点の有無。


初見時、わけわかんない・・・かも。
自分自身、そう思ったが、今の自分にはこれ以上のアルゴリズムは考えられない。マニュアルを読まなくても直感的に使えるプログラムが最もよいプログラムだと信じているが、ここだけはマニュアルを読んでクリアしてもらうしかなさそうだ。

このプログラムを使ってくださる方が、この世にいたとして・・・の話だが。

NPOフィールドにはチェックボックスを埋め込むことも考えた、いや、埋め込んでみたのだが、イマイチその挙動が気に入らない。これはどうしても必要となったら再考することにして、今は組み合わせ採点の実現を最優先することにする。

アルゴリズムは出来た。
さぁ 実装だ。

2.実装

追記_20250105
実装のプログラムコードは、次の記事に略した部分のない詳細があります。

(1)Gridコントロール

最初はGridコントロールの CMS フィールドへの入力から。

ここは、どう考えても自動入力にすべきだろう・・・。設計上、絶対に連番になっていないといけないし、100設問あるような場合、すべてを手入力するのはどう考えても時間の無駄だ。そう思って書いたのが次のコード。

  private
    { Private 宣言 }
    //StringGridの列数を設定 -> FormCreate時に設定する
    StrGrid1ColCount: Integer;
procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin

  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;

  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;
end;

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したときのイベント
  StringGrid1.MouseToCell(X, Y, ACol, ARow);
  if (ACol = StrGrid1ColCount-1) and (ARow >= 0) then
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;


実行時の動作は、次の通り。
CMS フィールドの1行目のセルをクリックして選択し、Enter キーを押し下げして選択セルを下に移動させると連番が自動的に入力される。

Enter キー押し下げでCMS列のすべての行が自動入力される。


組み合わせ採点を設定したいセルのみ、手動入力する。例えば設問番号2~4を組み合わせ採点したい場合は、2行目は自動入力で2が入るので、3行目・4行目に手動入力で半角数字の 2 を入力する。

組み合わせ採点したいセルには同じ値を入力する。


使ってみて気づいたのだが、この入力方法には問題があって、微調整が効かない!
途中で設定の誤りに気がついて、訂正しようとすると、訂正箇所以下すべての設定が失われてしまう・・・

2行目を選択してEnter キーを押し下げで、すべての設定が消える!


これは、さすがにマズい。部分修正しても、既存の組み合わせ採点設定が消えないようにする必要がある。どうするか? しばし考えて CheckBox と Button を1つずつ追加。

CheckBox のキャプションには「Auto」、Buttonのキャプションには「HELP」を設定。


CMS フィールドの自動入力は、Auto にチェックが入っているときのみ動作するよう設定を変更。これで既存の設定が一瞬にして消える悲劇は防げる? もちろん、デフォルトはFalse!

で、HELP ボタンをクリックしたら、CMS・NPO 各フィールドの意味と設定方法を表示。

説明は、必要最小限にしたつもり・・・だが。


次は、NPO フィールドへの入力。

いちばん、かんたんな方法は何か? いろいろ考えた末、説明されなければ絶対わからないが、説明さえきちんと読んでもらえれば、多分、便利に使える方法を採用。

それはクリックされた NPO フィールドのセル位置に応じて、組み合わせ採点の範囲を自動的に取得し、クリックされたセルとその上下の( CMS フィールドに同じ組み合わせ採点番号が設定されている)セルすべてに 1 (順不同採点ありのフラグとして利用)を自動入力するというもの。

NPO フィールドの任意のセルをクリックすると、
組み合わせ採点設定されている範囲のセルすべてに1を自動入力。


コードは次の通り。

private
  procedure UpdateColumnData(Value: Integer; IsChecked: Boolean);

procedure TForm1.UpdateColumnData(Value: Integer; IsChecked: Boolean);
var
  i: Integer;
  NewValue: string;
begin
  if IsChecked then
  begin
    NewValue := '1';
  end else begin
    NewValue := '0';
  end;
  for i := 1 to StringGrid1.RowCount - 1 do
  begin
    if StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, i]) = Value then
    begin
      StringGrid1.Cells[StrGrid1ColCount-1, i] := NewValue;
    end;
  end;
  //再描画をトリガ(即座に変更を表示)
  StringGrid1.Invalidate;
end;

procedure TForm1.StringGrid1MouseUp(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  ACol, ARow: Integer;
begin
  //マウスでクリックして、指を離したとき実行
  StringGrid1.MouseToCell(X, Y, ACol, ARow);
  //0行目(FixedRow)では動作しないように設定
  if (ACol = StrGrid1ColCount-1) and (ARow > 0) then
    //UpdateColumnData(ARow);
    //引数にはCMS設定値が入る
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, ARow]), True);
end;

解除は、解除したい組み合わせ採点範囲の任意のセル1つをクリック(選択)して、スペースキー押し下げ。これでクリックされたセルとその上下の( CMS フィールドに同じ組み合わせ採点番号が設定されている)セルすべてに 0(順不同採点なしのフラグとして利用)を自動入力。

NPO フィールドの任意のセルをクリックして選択し、
スペースキーを押し下げで、順不同採点設定を解除。


コードは、次の通り。

private
  procedure ToggleSGCell(ACol, ARow: Integer);

procedure TForm1.ToggleSGCell(ACol, ARow: Integer);
begin
  //現在の値をトグル
  if StringGrid1.Cells[ACol, ARow] = '1' then
    StringGrid1.Cells[ACol, ARow] := '0'
  else
    StringGrid1.Cells[ACol, ARow] := '1';

  //再描画をトリガ
  StringGrid1.Invalidate;
end;

procedure TForm1.StringGrid1KeyDown(Sender: TObject; var Key: Word;
  Shift: TShiftState);
begin
  //スペースキーでチェックボックスをトグル
  if (StringGrid1.Col = StrGrid1ColCount-1) and (StringGrid1.Row > 0) and (Key = VK_SPACE) then
  begin
    ToggleSGCell(StringGrid1.Col, StringGrid1.Row);
    UpdateColumnData(StrToInt(StringGrid1.Cells[StrGrid1ColCount-2, StringGrid1.Row]), False);
    Key := 0;
  end;
end;

procedure TForm1.StringGrid1MouseDown(Sender: TObject; Button: TMouseButton;
  Shift: TShiftState; X, Y: Integer);
var
  Col, Row: Integer;
begin
  //マウスクリックでGridのセルをトグル
  StringGrid1.MouseToCell(X, Y, Col, Row);
  if (Col = StrGrid1ColCount-1) and (Row > 0) then
    ToggleSGCell(Col, Row);
end;

これでフラグの準備が出来た。次は「組み合わせ採点」そのものの実装だ。

(2)組み合わせ採点

自作の採点結果通知個票作成プログラムでは、マークシートリーダーで読み取った解答用紙のマークの選択肢番号を記録した CSV ファイルを読み込み、その内容をGrid コントロールに表示している。

採点結果通知個票作成プログラム側で作成した、上記の正解データや観点別評価の種類、組み合わせ採点の有無、順不同採点の設定は、また別の CSV ファイルに保存している。

組み合わせ採点を行うには、その2つの CSV ファイルからデータを読み込み、組み合わせ採点設定に応じて、マークの状態と正解及び採点結果(True / False)を動的配列に格納する必要がある。なので、まず、それを準備する。

type
  //動的配列の宣言(配列要素の並べ替え他)
  TString2DArray = array of array of string;
  TString1DArray = array of string;
  TString2DBoolArray = array of array of Boolean;

procedure TForm1.TM(Sender: TObject);
var
  intQ: Integer  //設問数
  intCMS: Integer;  //組み合わせ採点数
  pArr: array of Integer;  //配点を入れる動的配列
  cArr: array of Integer;  //正解を入れる動的配列
  kArr: array of Integer;  //観点別評価の区分を入れる動的配列
  c4_Arr: array of Integer;  //CMS設定番号を入れる動的配列
  c5_Arr: array of Integer;  //NPO設定番号を入れる動的配列
  mArr: array of array of Integer;  //マークを入れる2次元の動的配列
  sArr: array of array of Boolean;  //採点結果を入れる2次元の動的配列
  cms_mArr: TString2DArray;  //マークの組み合わせを入れる2次元の動的配列
  cms_cArr: TString1DArray;  //正解の組み合わせを入れる1次元の動的配列
  cms_sArr: TString2DBoolArray;  //採点結果をTrue or Falseで保存
  cms_jArr: array of Boolean;  //順不同採点の実施の有無をTrue or Falseで保存

プログラムコードは、

//注意:コードは一部の抜粋(重要な部分のみ)であり、これだけでは動作しません。
//一部の変数は、説明用の文字列で代替しています。
var
  //マークを取得_20250228訂正
  function GenerateDynamicArray: TArray<string>;
  var
    i,j: UInt64;  #jを追加
    CurrentValue, NextValue: string;
    ResultArray: TArray<string>;
    TempStr: string;
  begin

    TempStr := '';
    j:=0;  #初期化
    for i := 1 to StringGrid1.RowCount - 2 do
    begin
      CurrentValue := StringGrid1.Cells[4, i];
      NextValue := StringGrid1.Cells[4, i + 1];

      if CurrentValue = NextValue then
      begin
        TempStr := TempStr + IntToStr(mArr[i-1,'答案画像の番号']);
      end else begin
        TempStr := TempStr + IntToStr(mArr[i-1,'答案画像の番号']);
        ResultArray := ResultArray + [TempStr];
        TempStr := '';
      end;
      j:=i;  #値を取得
    end;

    //最後の要素を追加_20250228訂正
    //TempStr := TempStr + StringGrid1.Cells[0, StringGrid1.RowCount - 1];
    TempStr := TempStr + IntToStr(mArr[j, '答案画像の番号']);
    ResultArray := ResultArray + [TempStr];

    Result := ResultArray;
  end;

  //正解を取得
  function GenerateDynamicArray2: TArray<string>;
  var
    i: UInt64;
    CurrentValue, NextValue: string;
    ResultArray: TArray<string>;
    TempStr: string;
  begin

    TempStr := '';
    for i := 1 to StringGrid1.RowCount - 2 do
    begin
      CurrentValue := StringGrid1.Cells[4, i];
      NextValue := StringGrid1.Cells[4, i + 1];

      if CurrentValue = NextValue then
      begin
        //正解を取得
        TempStr := TempStr + StringGrid1.Cells[1, i];
      end else begin
        //正解を取得
        TempStr := TempStr + StringGrid1.Cells[1, i];
        ResultArray := ResultArray + [TempStr];
        TempStr := '';
      end;
    end;

    //最後の要素を追加_20250228訂正
    //TempStr := TempStr + StringGrid1.Cells[0, StringGrid1.RowCount - 1];
    TempStr := TempStr + StringGrid1.Cells[1, StringGrid1.RowCount - 1];
    ResultArray := ResultArray + [TempStr];

    Result := ResultArray;
  end;

  //配列要素の並べ替え
  procedure SortStringWithZeroPriority(var Str: string);
  var
    CharArray: array of Char;
    i, j: Integer;
    Temp: Char;
  begin
    // 文字列を文字配列に変換
    SetLength(CharArray, Length(Str));
    for i := 1 to Length(Str) do
      CharArray[i - 1] := Str[i];

    // 昇順にソート (バブルソートを使用)
    for i := Low(CharArray) to High(CharArray) - 1 do
      for j := i + 1 to High(CharArray) do
      begin
        if (CharArray[j] = '0') or (CharArray[i] > CharArray[j]) then
        begin
          Temp := CharArray[i];
          CharArray[i] := CharArray[j];
          CharArray[j] := Temp;
        end;
      end;

    // ソートされた文字配列を元の文字列に戻す
    Str := '';
    for i := Low(CharArray) to High(CharArray) do
      Str := Str + CharArray[i];
  end;

begin

  //設問数を取得
  intQ:=StringGrid1.RowCount-1;

  //組み合わせ採点数を取得する -> 組み合わせ採点数は、最終行の値
  intCMS:=StrToInt(StringGrid1.Cells[4,intQ]);

  //動的配列を生成
  SetLength(cArr, intQ);  //正解(Correct answer)
  SetLength(pArr, intQ);  //配点(Point allocation)
  SetLength(kArr, intQ);  //観点別評価の区分
  SetLength(c4_Arr, intQ);  //組み合わせ採点の区分
  SetLength(c5_Arr, intQ);  //順不同採点の区分

  //正解・配点・観点別評価の区分を配列に取得
  for i := 1 to intQ do
  begin
    if StringGrid1.Cells[2,i]<>'' then
    begin
      cArr[i-1]:=StrToInt(StringGrid1.Cells[1,i]);
      pArr[i-1]:=StrToInt(StringGrid1.Cells[2,i]);
      kArr[i-1]:=StrToInt(StringGrid1.Cells[3,i]);
      c4_Arr[i-1]:=StrToInt(StringGrid1.Cells[4,i]);
      c5_Arr[i-1]:=StrToInt(StringGrid1.Cells[5,i]);
    end else begin
      pArr[i-1]:=0;
    end;
  end;

  //1問1答の通常採点用の配列を準備
  SetLength(mArr, intQ, ListBox1.Items.Count);  //マーク読み取り結果
  SetLength(sArr, intQ, ListBox1.Items.Count);  //採点結果

  //組み合わせ採点用の配列を準備
  SetLength(cms_mArr, intCMS, ListBox1.Items.Count);  //マーク読み取り結果の組み合わせ
  SetLength(cms_cArr, intCMS);  //正解読み取り結果の組み合わせ
  SetLength(cms_sArr, intCMS, ListBox1.Items.Count);  //組み合わせの採点結果
  SetLength(cms_jArr, intCMS);  //順不同採点実施の有無

  //まず全てのデータを取得する
  //マークを配列に取得・採点結果の初期化(False)
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin
    for j := 1 to intQ do  //設問数分Loopする
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        //空欄(999)も、ダブルマーク(99)もそのまま取得する
        mArr[j-1][i-1]:=StrToInt(strGrid.Cells[j,i]);
        //デフォルトFalseで初期化
        sArr[j-1][i-1]:=False;
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;
  end;

  //組み合わせ採点用の動的配列にデータをセットする
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin

    //マークを配列に取得・採点結果の初期化(False)
    DynamicArray := GenerateDynamicArray;
    for j := 0 to intCMS-1 do
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        cms_mArr[j][i-1]:=DynamicArray[j];
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;

    //正解を配列に取得・採点結果の初期化(False)
    DynamicArray := GenerateDynamicArray2;
    for j := 0 to intCMS-1 do
    begin
      if strGrid.Cells[j,i]<>'' then
      begin
        cms_cArr[j]:=DynamicArray[j];
      end else begin
        mArr[j-1][i-1]:=999;  //Gridが空欄であればマークは空欄として扱う
        sArr[j-1][i-1]:=False;
      end;
    end;
  end;

  //答案枚数分Loop
  for i := 1 to ListBox1.Items.Count do
  begin
    //組み合わせ採点数分Loop
    for j := 0 to intCMS-1 do
    begin      
      //もし、マークが正解と等しかったら
      if cms_mArr[j][i-1]=cms_cArr[j] then
      begin
        cms_sArr[j][i-1]:=True;
      end else begin
        cms_sArr[j][i-1]:=False;      
      end;
    end;
  end;

実行(F9)結果は・・・

全問正解で処理した場合
全問不正解で処理した場合
(採点記号・観点別評価の区分に加えて、正解の選択肢を赤字で表示することも可能)


期待した通りに動作しているようだ。

うれしい・・・ことに間違いはないのだが、感極まるような喜びはない。正直なところ、あまりにも簡単に( 絶対! 出来ない )と思い込んでいたことができちゃったので( そんなもんか・・・ )みたいな。

(3)順不同採点

次は、順不同採点だ。アルゴリズムは出来ている。上で作成済みの「マークされた選択肢の番号を入れた動的配列の要素」と、「正解の選択肢の番号を入れた動的配列の要素」をそれぞれ昇順(別に降順でも構わないが)に並び替え、比較して一致した場合を正解として処理すればよい。

var
  CurrentCMSValue: UInt64;

  //配列要素の並べ替え
  procedure SortStringWithZeroPriority(var Str: string);
  var
    CharArray: array of Char;
    i, j: Integer;
    Temp: Char;
  begin
    // 文字列を文字配列に変換
    SetLength(CharArray, Length(Str));
    for i := 1 to Length(Str) do
      CharArray[i - 1] := Str[i];

    // 昇順にソート (バブルソート)
    for i := Low(CharArray) to High(CharArray) - 1 do
      for j := i + 1 to High(CharArray) do
      begin
        if (CharArray[j] = '0') or (CharArray[i] > CharArray[j]) then
        begin
          Temp := CharArray[i];
          CharArray[i] := CharArray[j];
          CharArray[j] := Temp;
        end;
      end;

    //ソートされた文字配列を元の文字列に戻す
    Str := '';
    for i := Low(CharArray) to High(CharArray) do
      Str := Str + CharArray[i];
  end;

begin
  //組み合わせ採点用の動的配列にデータをセットする
  for i := 1 to ListBox1.Items.Count do  //答案枚数分Loopする
  begin
    ・・・
  end;

  //順不同採点のフラグを設定
  for i := 1 to StringGrid1.RowCount-1 do
  begin
    if StringGrid1.Cells[2, i] <> '0' then
    begin
      CurrentCMSValue := StrToInt(StringGrid1.Cells[4, i]);
      case StrToInt(StringGrid1.Cells[5, i]) of
        0:begin
          cms_jArr[CurrentCMSValue-1]:= False;
        end;
        1:begin
          cms_jArr[CurrentCMSValue-1]:= True;
        end;
      end;
    end;
  end;

  //答案枚数分Loop
  for i := 1 to ListBox1.Items.Count do
  begin
    //組み合わせ採点数分Loop
    for j := 0 to intCMS-1 do
    begin

      //順不同採点を実施する場合の処理
      if cms_jArr[j] then
      begin
        //マーク並べ替え
        SortStringWithZeroPriority(cms_mArr[j][i-1]);
        //正解並べ替え
        SortStringWithZeroPriority(cms_cArr[j]);        
      end;

      //もし、マークが正解と等しかったら
      if cms_mArr[j][i-1]=cms_cArr[j] then
      begin
        //採点結果をTrue
        cms_sArr[j][i-1]:=True;
      end else begin
        cms_sArr[j][i-1]:=False;
      end;

    end;
  end;

end;

実行(F9)時の画面は、次の通り。まず、順不同採点を行わない場合、

組み合わせ採点が有効で、順不同採点は無効として採点。
マークは「1・2・3」なので不正解になる。


順不同採点を行う場合、

組み合わせ採点・順不同採点ともに有効として採点。
マークが「1・2・3」でも正解になる。

3.お知らせ

今回紹介した組み合わせ採点機能を組み込んだ採点結果通知個票作成用のプログラムは、実際の試験で必要十分な動作検証を行い、後日、「ReportCard_2025.exe」として公開する予定です。

4.お願いとお断り

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

Checked プロパティのみ設定したい!

CheckBox がクリックされたら「メッセージを表示」して、ユーザーに「はい」・「いいえ」のいずれかを選択してもらう。

「はい」が選択された場合はプログラム自体を再起動。で、再起動後の FormCreate 時に当該 CheckBox の Checked プロパティをクリックされた(変更された)状態に設定。ただし、その際、メッセージは表示しない。

もし、「いいえ」が選択された場合は、CheckBox の Checked プロパティはチェック前の状態を維持、つまり、クリックを無効化する。もちろん、ここでもメッセージは出さずに、Checked プロパティのみ修正したい。

この動作を実現したくて、半日、ハマった。

【もくじ】

1.用意した手続きと関数
2.実行結果
3.お願いとお断り

1.用意した手続きと関数

なんとか、実現。完成したコードは以下の通り。

  private
    { Private 宣言 }

    //チェックボックスの状態をロード中に OnClick イベントがトリガーされるのを防止する
    IsLoading: Boolean;

    procedure SaveCheckCMS_State(CheckBox: TCheckBox);  //Checked プロパティを保存
    procedure LoadCheckCMS_State(CheckBox: TCheckBox);  //Checked プロパティを読込
    procedure ClearRestartFlag;  //再起動フラグをクリア
    function IsRestarting: Boolean;  
    procedure RestartApplication;


グローバル変数を一つ、手続きと関数を上のように準備。それから ini ファイルを使うので、System.IniFiles を uses に追加。

implementation

uses
  System.IniFiles;

Shift+Ctrl+C でそれぞれの手続きや関数を次のように作成。

まず、SaveCheckCMS_State 手続き。CheckCMS が CheckBox の名前。Checked プロパティの状態を保存する。ちなみに CMS は、組み合わせ採点(Combined Scoring Method)の略。

procedure TForm1.SaveCheckCMS_State(CheckBox: TCheckBox);
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IniFile.WriteBool('セクション', '組み合わせ採点', CheckCMS.Checked);
    IniFile.WriteBool('セクション', 'IsRestarting', True); //再起動フラグを設定
  finally
    IniFile.Free;
  end;
end;

次は LoadCheckCMS_State 手続き(保存した Checked プロパティの状態を読み込む)。

procedure TForm1.LoadCheckCMS_State(CheckBox: TCheckBox);
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IsLoading := True; // イベントを無効にするためのフラグを設定
    CheckCMS.Checked := IniFile.ReadBool('セクション', '組み合わせ採点', False);
  finally
    IsLoading := False; // フラグをリセット
    IniFile.Free;
  end;
end;

次は ClearRestartFlag 手続き( Checked プロパティの保存時に True に設定した再起動を知るフラグをクリアする)。

procedure TForm1.ClearRestartFlag;
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    IniFile.WriteBool('セクション', 'IsRestarting', False);  //再起動フラグをクリア
  finally
    IniFile.Free;
  end;
end;

次は IsRestarting 関数( FormCreate 時に呼び出し)。

function TForm1.IsRestarting: Boolean;
var
  IniFile: TIniFile;
begin
  IniFile := TIniFile.Create(ChangeFileExt(Application.ExeName, '.ini'));
  try
    Result := IniFile.ReadBool('セクション', 'IsRestarting', False);
  finally
    IniFile.Free;
  end;
end;

次は RestartApplication 手続き。これを呼び出すことでプログラム自体を再起動する。

procedure TForm1.RestartApplication;
var
  FileName: string;
  StartupInfo: TStartupInfo;
  ProcessInfo: TProcessInformation;
begin

  FileName := ParamStr(0);
  ZeroMemory(@StartupInfo, SizeOf(StartupInfo));
  StartupInfo.cb := SizeOf(StartupInfo);
  ZeroMemory(@ProcessInfo, SizeOf(ProcessInfo));

  if CreateProcess(PChar(FileName), nil, nil, nil, False, 0, nil, nil, StartupInfo, ProcessInfo) then
  begin
    CloseHandle(ProcessInfo.hProcess);
    CloseHandle(ProcessInfo.hThread);
  end;

  Application.Terminate;

end;

以上のように手続き・関数を準備して、FormCreate 時の設定。

procedure TForm1.FormCreate(Sender: TObject);
begin

  //チェックボックスの状態をロード中に OnClick イベントがトリガーされるのを防止する
  IsLoading:=False;

  LoadCheckCMS_State(CheckCMS);  //Checked プロパティを復元
  if IsRestarting then
    ClearRestartFlag;  //再起動フラグをクリア

end;

最後に、いちばん肝心な CheckCMSClick 手続き。実際は、ここからすべてが始まる。

procedure TForm1.CheckCMSClick(Sender: TObject);
var
  strMsg: string;
begin
  //再起動状態でなければ実行
  if not IsLoading then
  begin
    SaveCheckCMS_State(CheckCMS);  //Checked プロパティを保存

    //最初はコレでいいかと思ったんだけれど・・・あまりにも乱暴な気が。
    //strMsg:='設定はプログラムの再起動後に有効になります。'+#13#10+
    //  'OKで再起動します。';
    //Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    //RestartApplication;

    //操作の取り消しができるように修正
    strMsg:='設定はプログラムの再起動後に有効になります。'+#13#10+
      '再起動してよろしいですか?';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      //[はい]が選ばれた時
      RestartApplication;
    end else begin
      //[いいえ]が選ばれた時
      //メッセージを表示せず、チェックボックスの状態のみ変更
      if CheckCMS.Checked then
      begin
        CheckCMS.OnClick := nil;  //OnClickイベントを一時的に無効にする
        CheckCMS.Checked := False;
        CheckCMS.OnClick := CheckCMSClick;  //OnClickイベントを再度設定
      end else begin
        CheckCMS.OnClick := nil;  //OnClickイベントを一時的に無効にする
        CheckCMS.Checked := True;
        CheckCMS.OnClick := CheckCMSClick;  //OnClickイベントを再度設定
      end;
    end;

  end;
end;

2.実行結果

(1)プログラムを起動。フォームが表示される。

練習用なので、CheckBox をひとつだけ用意。
CheckBox の Checked プロパティはデフォルトでは False に設定している。


(2)CheckBoxをクリックすると、メッセージが表示されるので、「はい」をクリックする。


(3)自分自身を再起動。CheckBox の Checked プロパティは終了時の True 状態で起動するが、上記のメッセージは表示されない。 これが実現したかったことのひとつめ。

Checked プロパティは True でも、メッセージは表示されない。


(4)再度、CheckBox をクリック。Checked プロパティは False に変わり、CheckBox のチェックは外れた状態でメッセージが表示される。今度は「いいえ」をクリック。

今度は「いいえ」をクリックする。


(5)「いいえ」を選択したから再起動はしない。「再起動しない」から CheckBox の Checked プロパティは元の True であった状態を維持(= False から True へ修正)するが、メッセージは表示されない。これが実現したかったことのふたつめ。

「いいえ」が選択された場合は、CheckBox の Checked プロパティはチェック前の状態を維持。
(直前のクリックを無効化)

3.お願いとお断り

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

DelphiのSmart CodeInsightを使ってみました!

Delphi 12.2で利用できるようになったSmart CodeInsightが使えるようになるまでの設定方法です。
ローカルで LLM を実行できる Ollama の環境構築から(見様見真似で)やってみました。

【もくじ】

1.はじめに
2.Ollama のインストール
3.LLM モデルのダウンロード
(1)Gemma 2
(2)Llama-3-ELYZA-JP-8B
4.動作環境の設定
(1)Gemma 2
(2)Llama-3-ELYZA-JP-8B
5.DelphiのIDEのスマート支援機能を設定
6.DelphiのIDEのスマート支援機能を使う
7.まとめ
8.お願いとお断り

1.はじめに

ちなみに LLM は(今回、初めて知った!のですが)、自然言語処理( Natural Language Processing :NLP )のタスクに使用される大規模言語モデル( Large Language Model )の略で、膨大な量のテキストデータを使って訓練された人工知能のモデルを意味するそうです。

※ 自然言語処理のタスク:「コンピュータがヒトの言語を理解し、生成し、処理する上での特定の課題や目的」のこと。すなわち、文章の生成、分類、翻訳、応答、人名・地名・組織名等の特定の名称認識( Named Entity Recognition:NER )、音声認識、要約など、実に様々な「タスク」があるようです。

調べてみると実にたくさんの LLM があり、果たしてどのモデルを選べばよいのか(例えば、日本語が得意で、プログラミングに適したモデルはどれなのか?)がわからず、当初、たいへん困りましたが、いくつかの Web サイトの情報を参考に、ここでは「 Gemma 2 」と「 Llama-3-ELYZA-JP-8B 」をダウンロードして使ってみました。

コマンドを叩くだけで簡単にインストールできたのは「 Gemma 2 」、自分で Modelfile を作る必要があり、設定に少し勉強が必要だったのが「 Llama-3-ELYZA-JP-8B 」です。私のノート PC 環境(もちろん、GPU などという結構なモノは、ハナからついておりません!)で、「実用になるか・どうかは別」にして、単に「応答の速さだけを見た」場合、後者の方が体感的には「圧倒的に速かった」です。

【My デバイスの仕様】
プロセッサ 11th Gen Intel(R) Core(TM) i7-1185G7 @ 3.00GHz 3.00 GHz
実装 RAM 32.0 GB (31.7 GB 使用可能)
システムの種類 64 ビット オペレーティング システム、x64 ベース プロセッサ

2.Ollama のインストール

Delphi 12.2 Athens をインストールして、その新機能に関する記事を読んでいたら、「Smart CodeInsight: コーディングにAIのパワーを活用」という見出しがあり、そのリンク先の記事で、オンラインソリューションや、オフラインソリューションを使用して AI LLM をコーディングに活用できることを知りました。これが今回の事始めです。ドキドキ。

あっ! ちなみに Ollama は「オラマ」と読むようです。ほんとか・どうか、わかりませんが・・・

https://blogs.embarcadero.com/ja/announcing-the-availability-of-rad-studio-12-2-athens-ja/#Smart_CodeInsight_%E3%82%B3%E3%83%BC%E3%83%87%E3%82%A3%E3%83%B3%E3%82%B0%E3%81%ABAI%E3%81%AE%E3%83%91%E3%83%AF%E3%83%BC%E3%82%92%E6%B4%BB%E7%94%A8

Windows11 の 23H2 で Copilot に触れてから、生成AIにコーディングを助けてもらうことが断然多くなりました。もし、DelphiのIDE上で生成AIが利用できたら、どれほど快適にプログラムが書けることか、想像するだけでワクワクします。これまでGoogle先生に質問をくり返しながら、四苦八苦していたことが、まるで夢のよう・・・。

そう思いつつ、別の記事を参照して、追加の情報もGet。

https://blogs.embarcadero.com/ja/using-ai-llms-in-the-rad-studio-ide-with-smart-codeinsight-ja/

上の記事によれば、オンラインソリューションは基本的に全て有料とのこと。で、唯一、オフラインソリューションの Ollama だけが無料で使えるソリューションでした。

※ ソリューション:「問題解決方法、手段、対応策」

上記リンク先の記事からの引用です。

「アカウントと支払いに関して唯一の例外はOllamaで、Ollama はローカル (または任意のサーバー) にインストールでき、サービス料金を支払うことなくオフラインで使用できます。」

RAD Studio IDEで「AI LLM」と「Smart CodeInsight」を使用する より引用

・・・ということなので、迷うことなく Ollama に決めました。で、Ollama は、MacOS 版、Linux 版、Windows 版があるとのことで、Windows のユーザーである私はもちろん Windows 版をチョイス。

https://ollama.com/download

上記リンク先から( Windows 環境であれば Windows 版の ) Ollama をダウンロードしました。

特に意識しなくても、自動でWindows版が選択されていた気が・・・しますが、OS が Windows11 であればダウンロードフォルダに OllamaSetup.exe(version 0.3.14:2024/10/29 現在)が保存(663MB)されます。これをダブルクリックしてインストール。

インストール自体は、ただ待つだけ。何の問題もなく終了。

(設定で、何か変わったところはあるのかなー?)

・・・と思って、いちおう確認すると、環境変数のPathの最後に

C:\Users\ユーザー名\AppData\Local\Programs\Ollama

が追加されてました。変更は、はたして、これだけか?

ここでの表記は「Ollama」
「ollama」じゃなくて、「Ollama」が正しい表記なのだろうか?


とりあえず、コマンドプロンプトを起動し、次のコマンドを叩きます。

ollama -v

【実行結果】


大丈夫。ダウンロード & インストールは無事完了!

3.LLM モデルのダウンロード

Ollama のインストールが終了したら、Ollama を使って実行する LLM のモデルを入手しなければなりません。「 LLM 日本語 無料 おすすめ」等のキーワードで検索してみると、実にたくさんの LLM モデルがあることに気付きます。

( 百花繚乱・・・)

LLM は、まさに現在進行形で進化中、きっとお互いにしのぎを削っているような状況なのでしょう。

(1)Gemma 2

いくつかの Web サイトを参照して、まず「Gemma(ジェマ)」という LLM を試してみようかと思いました。正直、専門的なことは「チンプンカンプン」で「まったくわからない」私ですが、様々なサイトで「高性能」と評価されていたこと、そして何より、インストールがとても簡単そうだったのがいちばんの理由です。

スタートボタンの隣にある「検索」欄に「cmd」と入力してコマンドプロンプトを起動、んで、次のコマンドを叩くだけで Gemma2 モデルがダウンロードされて、ただちに起動しました。カンタン!

これ作ったひと、すごい! これ作ったひとみたいに、なりたいなー☆

ollama run gemma2
C:\Users\ユーザー名>ollama run gemma2
pulling manifest
pulling ff1d1fc78170... 100% ▕██████████████|略|██████████████▏ 5.4 GB
pulling 109037bec39c... 100% ▕██████████████|略|██████████████▏  136 B
pulling 097a36493f71... 100% ▕██████████████|略|██████████████▏ 8.4 KB
pulling 2490e7468436... 100% ▕██████████████|略|██████████████▏   65 B
pulling 10aa81da732e... 100% ▕██████████████|略|██████████████▏  487 B
verifying sha256 digest
writing manifest
success
>>> Send a message (/? for help)

いくつか質問してみましたが、英語での質問には英語で、日本語での質問には日本語で答えてくれました。さらにいちばん気になる Object Pascal に関する質問にも、よさげな返事を返してくれました。

(2)Llama-3-ELYZA-JP-8B

もうひとつ気になった LLM が「 Llama-3-ELYZA-JP-8B 」です。なぜ、気になったかというと・・・

・「日本語に特化している」という情報が得られたこと。
・「小さい、軽量、ローカルでの実行に適している」という記述が多数のサイトで見られたこと。
・「Code Llama」を含む「Llama3(ラマ3)」がベースになっていること。

これ以外にもたくさんの情報がありましたが、自分的にはこの3つだけでもう十分に魅力的だと感じました。特に、最後の「Code Llama」というプログラム作成を支援してくれる機能が含まれているという解説は「 Delphi を愛して十数年・・・これを試さずには死ねない!」と思うほど、強烈な輝きを放っていました。びゃかー!ってカンジです。あぁ目が眩む。

さて、肝心のダウンロードですが、次のサイトの情報に従って行いました。

「よーしパパ、Ollama で Llama-3-ELYZA-JP-8B 動かしちゃうぞー」

https://qiita.com/s3kzk/items/3cebb8d306fb46cabe9f

上記サイトの記事の作成者様に心から感謝申し上げます。

まず、上記サイトの案内にある通り、「 Hugging Face 」から、「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」をダウンロードしました。

他にも、よくわからないファイルがたくさんあったけど、とにかく「Llama-3-ELYZA-JP-8B-q4_k_m.gguf」だけををダウンロード!

ファイルの大きさは 4.58 GBほどありました。

4.動作環境の設定

(1)Gemma 2

設定は必要ありませんでした。上の記事に書いた通り、コマンドプロンプトを起動して、

ollama run gemma2

これだけで Gemma2 が起動、使用できました。

(2)Llama-3-ELYZA-JP-8B

こちらのモデルは、Ollama で標準サポートされているモデルではない(2024年10月現在)ため、使用するには少し事前準備が必要でした。以下、見様見真似で行った準備作業の手順とその内容です。

・Modelfile という「拡張子のない」ファイルをメモ帳などのテキストエディタで作成する。
・ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf と Modelfile を任意の場所にコピーする。
・コマンドを実行して Modelfile から Ollama 用のモデルを作成する。

最初に行ったのは、Modelfile の作成です。Llama-3-ELYZA-JP-8B はその名前を見れば、Llama3 モデルの発展型(そう呼んでいいのかな?)であることが明らかですから、モデルの作成に使用するフォーマットは、Llama3 モデルのそれと同じになるように設定する必要があるとのこと。なので Modelfile はモデルのフォーマット方法を書いたものなんだと理解しました。

※ フォーマット:「モデルがどのようにデータを扱い、出力を生成するか」について、その形式や構造を決めたもので、モデルが入力を理解できるように、また、効率的にデータを処理できるようにするためのルールや方法が定義されている。

【作成した Modelfile 】

作成にあたって、複数のWebサイトにあっ たModelfile の例を調べましたが、Webサイトによって、下の例の最後の行:PARAMETER stop “<|reserved_special_token” 部分が「ない」などの違いがありました。ちなみにこれは、特定のトークン(単語、フレーズ、または文字)に到達した際に出力を停止するようにモデルに指示、つまり特定の条件で出力を停止させることで、予期せぬ長い出力を防ぐために設定するパラメータのようです。

FROM ./Llama-3-ELYZA-JP-8B-q4_k_m.gguf
TEMPLATE """{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"""
PARAMETER stop "<|start_header_id|>"
PARAMETER stop "<|end_header_id|>"
PARAMETER stop "<|eot_id|>"
PARAMETER stop "<|reserved_special_token"

次に作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の保存先ですが、どこにしまったらいいのか、さっぱりわかりません。いろいろ調べてみると、この2つのファイルの保存場所は「任意のフォルダ」としているサイトが数多く見受けられました。これより、この2つのファイルは同じフォルダ内に置けば、それでいいのではないかと思えてきました。

そこで「任意のフォルダ」に保存することに決めたのですが、その場所が次の大きな問題です。出来れば、あとから思い出せるように、「どこに保存したか?」絶対忘れない場所がいいです。

で、思いついたのが Ollama のインストール先フォルダのルートに置けばいいのではないか? ということです。調べて見ると Ollama のインストール先は次の場所でした。

C:\Users\ユーザー名\.ollama

さらに、「モデルがどこに作成されるのか」を調べたら、次の場所に作成されるようでした。

C:\Users\ユーザー名\.ollama\models

以上のことから、作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の2つのファイルを Ollama のインストール先フォルダのルートに保存し、このフォルダを作業ディレクトリにしてモデル作成のコマンドを叩けば上手く行く(= Llama-3-ELYZA-JP-8B のモデルの作成に成功する)のではないかと考え、次のように操作してみました。

まず、作成した Modelfile と、ダウンロードした Llama-3-ELYZA-JP-8B-q4_k_m.gguf の2つのファイルを C:\Users\ユーザー名\.ollama フォルダにコピペする。

「Modelfile」に拡張子はない!


次に、コマンドプロンプトを起動して、モデル作成のコマンドを実行して、Modelfile から Ollama のモデルを作成します。モデル作成のコマンドは、次の通りです。

ollama create elyza:jp8b -f Modelfile

この時、コマンドプロンプト起動直後の状態のまま、カレントディレクトリを .ollama に変更せずにモデル作成のコマンドを実行したらエラーが発生してしまいました。

C:\Users\ユーザー名>ollama create elyza:jp8b -f Modelfile

Error: open C:\Users\ユーザー名\Modelfile: The system cannot find the file specified.

( Modelfile が見えません・・・ あっ☆)

そこで次のようにしてカレントディレクトリを .ollama に変更。

C:\Users\ユーザー名>cd .ollama

もう一度、上記のモデル作成のコマンドを実行。

C:\Users\ユーザー名\.ollama>ollama create elyza:jp8b -f Modelfile
transferring model data 100%
using existing layer sha256:91553c45080b11d95be21bb67961c9a5d2ed7556275423efaaad6df54ba9beae
creating new layer sha256:8ab4849b038cf0abc5b1c9b8ee1443dca6b93a045c2272180d985126eb40bf6f
creating new layer sha256:c0aac7c7f00d8a81a8ef397cd78664957fbe0e09f87b08bc7afa8d627a8da87f
creating new layer sha256:bc526ae2132e2fc5e7ab4eef535720ce895c7a47429782231a33f62b0fa4401f
writing manifest
success

おしまいに success と表示されたので、上手くいったようです。
モデルを実行するには、次のコマンドを入力すればよいとのこと。

ollama run elyza:jp8b

やってみました!

C:\Users\ユーザー名\.ollama>ollama run elyza:jp8b
>>> Send a message (/? for help)

Send a message とある部分に、次のように入力してみました。

>>> DelphiでTLabelのAlignmentにtaCenterを指定しても実行すると設定が反映されないことがあります。どうしたらLabelのキャプションを中央揃えにして表示できますか?

入力後、Enterキーを叩くと・・・

TLabelのAlignmentにtaCenterを指定した際、設定が反映されない場合があります。この問題は、LabelのParentComponentにContainerControlを使用することで解決します。

以下の手順で解決します:

1. LabelをContainerControl (例えばTPanelやTFrame) のChildにします。
2. ContainerControlのAlignmentプロパティにtaCenterを指定します。

上記の方法で、Labelのキャプションが中央揃えになります。

AI の回答の内容の良否は検証の要有りですが、それより、何より・・・

動きましたー☆

5.DelphiのIDEのスマート支援機能を設定

これがいちばんやりたかったことです。

Delphiを起動して、「ツール」→「オプション」の順にクリックします。


オプションのダイアログ(ユーザーに情報を提供したり、ユーザーから入力を受け取るための小さなウィンドウ。ダイアログボックスともいう)が表示されたら、左ペインの「IDE」の中にある「スマート支援機能」をクリックします。

IDEは、前回、開いた場所を記憶しているようです。
必要であれば、「IDE」→「スマート支援機能」の順にクリックしてください。


次のように設定します。


(1)スマート支援機能の下にある「 Enable AI Engine 」のチェックをONにします。
(2)「エディタのデフォルト AI 」のComboBox は選択肢から Ollama を選択します。
(3)「チャットウインドウのデフォルト AI 」も選択肢から Ollama を選択します。

私の環境では、CheckBox のキャプションは「 Enable AI Engine 」でしたが、
Web上で見たダイアログではそれが「AIエンジンの有効化」となっていました。
なぜなんでしょう?


続けてプラグインの部分も次のように設定します。


(1)最初に「 Ollama 」タブをクリックして選択します。
(2)有効にチェックします。
(3)BaseURL に「 http://localhost:11434 」と入力します。
(4)LLM のモデルが Llama-3-ELYZA-JP-8B 場合、モデルには「 elyza:jp8b 」と入力してください。

モデルの設定部分については、何と設定すればいいのか? 当初わからなくて困ったのですが、コマンドプロンプトで Llama-3-ELYZA-JP-8B を実行する時に入力するコマンドが

C:\Users\ユーザー名\.ollama>ollama run elyza:jp8b

であることから、モデルの設定は「 elyza:jp8b 」に違いない!と考え、実際その設定で動作しましたので、多分、これで OK なのではないかと・・・思われます。

入力が終わったら「保存」ボタンをクリックして、オプション設定のダイアログを閉じてください。

6.DelphiのIDEのスマート支援機能を使う

さっそく使ってみます。既存のプロジェクトファイルを開くか、新規にアプリケーションを作成して、コードエディタが表示された状態にします。

編集画面の余白部分を右クリックすると、次の図のようにポップアップメニューが表示されるので、そのいちばん上にある「 Smart CodeInsight 」をポイント(or クリック)します。すると、さらにサブメニューが表示されます。

全部! 試してみたい機能ばかり☆


AIチャットをクリックしてみました☆

Ollama>に質問を入力すればよさそうです!


質問してみました!


少し、待ち時間がありましたが、待ちくたびれるほどではありません。ちゃんと計ったわけではありませんが 30 秒くらいかなー? AI の返事が表示されました。

表示されたコードは Object Pascal ではありませんでした!


質問に「 Object Pascal で書いて!」という内容を追加して再チャレンジ。

すごーい! すごーい!!


ふと思い立って・・・ DelphiのIDEのコードエディタに「VB.NET」のコードを貼り付けて、

ムチャしてます・・・


コード全体を選択して、コードの変換を試してみます。

「Delphi に変換」をクリック


かなり待ち時間がありましたが・・・

結果が表示されるまで、上のような画面になります。


3分くらいかな? ちょっと長かったけど、無事、変換できました!

すごーい! すごーい!!


もし、わからないコードがあった時は・・・

わからないコードを選択して、「コードの説明」をクリック


すると・・・

すごーい! すごーい!!


感動!

7.まとめ

(1)Ollama をインストールすれば、ローカルな環境でも LLM を利用した生成 AI を使用できる。
(2)LLM は Ollama で標準サポートされているものなら、run するだけで使える。
(3)Ollama で標準サポートされていない LLM でも gguf ファイルから create して使用できる。
(4)Delphi のIDEのスマート支援機能で Ollama を指定すれば、無料でローカル LLM を使える。
(5)GPU がないPCでも(待ち時間はあるが)スマート支援機能は使えそうな気がする。

8.お願いとお断り

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

Excelのプロセスを終了させる

プログラムがフリーズするなどして、実行中の Excel のプロセスが残ってしまうことがある。また、そうでなくても、例えば Delphi の Try ~Finally 文で、確実に Excel のオブジェクトを解放したつもりであるにもかかわらず、プログラムで読み書きした特定の Excel のファイルが「編集のため、ロックされています」と表示され、「読み取り専用」でしか開けなくなり、Ctrl+Alt+Del でタスクマネージャーを起動して確認すると、場合によっては5つも6つもExcelのプロセスが実行中であったり、する。

そこで Delphi で書いた Excel のファイルを操作するアプリケーションを終了する際に、実行中のプロセスが残らないようにする方法を考えてみた。

【もくじ】

1.確認メッセージを表示して終了させる
2.確認メッセージを表示せずに起動中の全てのプロセスを終了させる
3.お願いとお断り

1.確認メッセージを表示して終了させる

最初に書いてみたのがコレ!
Excel のプロセスが実行中であれば(残っていれば)、確認メッセージを表示して、プロセスを強制的に終了させる。ただし、このコードで終了できるプロセスは1つのみ。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
var
  strMsg: string;

  //Excelのプロセスが実行中であるか、どうかを調査する関数
  function IsExcelRunning: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          Result := True;
          Break;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

  //プロセスのリストを取得し、特定のプロセスを終了する関数
  function TerminateExcelProcesses: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
    ProcessHandle: THandle;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          ProcessHandle := OpenProcess(PROCESS_TERMINATE, False, ProcessEntry.th32ProcessID);
          if ProcessHandle <> 0 then
          begin
            if TerminateProcess(ProcessHandle, 0) then
            begin
              Result := True;
            end;
            CloseHandle(ProcessHandle);
          end;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

begin
  if IsExcelRunning then
  begin
    //Excelのプロセスを終了させる
    strMsg:='Excelのプロセスが実行中です。'+#13#10+#13#10+
      '終了してもよろしいですか?';
    if Application.MessageBox(PChar(strMsg), PChar('警告'), MB_YESNO or MB_ICONWARNING) = mrYes then
    begin
      //[はい]が選ばれた時
      if TerminateExcelProcesses then
      begin
        strMsg:='Excelプロセスを終了しました。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end else begin
        strMsg:='実行中のExcelプロセスは見つかりませんでした。';
        Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
      end;
    end else begin
      //[いいえ]が選ばれた時
      strMsg:='Ctrl+Alt+Delキーを同時に押してタスクマネージャーを起動し、実行中の'+
      'Excelのプロセスを必ず終了してください。';
      Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    end;
  end else begin
    strMsg:='Excelは実行されていません。';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
  end;

end;

ただ、コレだと、もし、Excelのプロセスが実行中であった場合、アプリケーションの終了時に突然表示されるメッセージに、ユーザーが驚き、「はい」・「いいえ」のどちらを選べばいいのか、操作上の混乱が生じる可能性があるように思えてきた・・・。

それに、複数の Excel のプロセスが実行中であった場合、この方法では1つしか、終了できない。

そこで、ユーザーには何も知らせずに、もし実行中の Excel のプロセスがあれば、バックグラウンドですべてのプロセスを終了させるようにプログラムを修正。それが次の「確認メッセージを表示せずに起動中の全てのプロセスを終了させる」例。

2.確認メッセージを表示せずに起動中の全てのプロセスを終了させる

実行中の全ての Excel のプロセスを強制的に終了させる。ユーザーに対する確認メッセージは表示しない。

procedure TForm1.FormCloseQuery(Sender: TObject; var CanClose: Boolean);

  //Excelのプロセスが実行中であるか、どうかを調査する関数
  function IsExcelRunning: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          Result := True;
          Break;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

  //プロセスのリストを取得し、特定のプロセスを終了する関数
  function TerminateExcelProcesses: Boolean;
  var
    Snapshot: THandle;
    ProcessEntry: TProcessEntry32;
    ProcessHandle: THandle;
  begin
    Result := False;
    Snapshot := CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if Snapshot = INVALID_HANDLE_VALUE then Exit;

    ProcessEntry.dwSize := SizeOf(TProcessEntry32);
    if Process32First(Snapshot, ProcessEntry) then
    begin
      repeat
        if SameText(ProcessEntry.szExeFile, 'EXCEL.EXE') then
        begin
          ProcessHandle := OpenProcess(PROCESS_TERMINATE, False, ProcessEntry.th32ProcessID);
          if ProcessHandle <> 0 then
          begin
            if TerminateProcess(ProcessHandle, 0) then
            begin
              Result := True;
            end;
            CloseHandle(ProcessHandle);
          end;
        end;
      until not Process32Next(Snapshot, ProcessEntry);
    end;
    CloseHandle(Snapshot);
  end;

begin

  //Excelのプロセスが実行中である限りLoopさせ、完全にExcelのプロセスを終了させる。
  While IsExcelRunning do
  begin
    TerminateExcelProcesses;
    Application.ProcessMessages;
  end;

end;

これが、いちばんスマートかな?

3.お願いとお断り

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

デジタル採点 All in One !

これまでに書いてきたデジタル採点プログラムをひとつにまとめました!

手書き答案採点・マークシートリーダー・採点結果通知&成績一覧表の作成プログラム


「AC_Reader」は、手書き答案のデジタル採点に、「MS_Reader」は、マークシート形式の試験のデジタル採点に、それぞれ使用します。

「ReportCard_2024」は、「AC_Reader.exe」及び「MS_Reader.exe」と連携して動作するプログラムで、受験者へのデジタル採点の採点結果を通知する個票及び採点者用の成績一覧表を作成することが出来ます。

「デジタル採点 All in One! 」では、3通りのデジタル採点の実行と、受験者に採点結果を通知する個票及び成績一覧表の作成が可能です。その概要は次の通りです。

1.マークシートの読み取りとデジタル採点
2.手書き答案のデジタル採点(縦書き・横書き、両方の答案に対応)
3.マークシートと手書きを併用した試験のデジタル採点

ただ、プログラミングには素人である筆者が作成したプログラムですので、使いにくいのはもちろんのこと、未発見の不具合もまだきっとあると思います・・・が、掲載したプログラムはすべて「実際に採点の現場で使用」し、動作確認を行ったもので、その際に発見できた不具合はすべて修正してあります。したがって、筆者の想定する範囲内での運用であれば、確実に動作するはずですが、ご使用に際しては事前に必要十分な試行・動作確認を行っていただけますよう、心からお願い申し上げます。

掲載したプログラムは、何の保証もサポートもありませんが、すべて無料でお使いいただけます。ただし、ご使用に際しては、完全に自己責任での運用をお願いいたします。ここに記載した内容及びダウンロードしたプログラムを利用した結果、利用者および第三者に損害が発生したとしても、このサイトの管理者は一切責任を負えません。予め、ご了承ください。

プログラムは今後も改良し続け、掲載したプログラムは随時改良版に更新する予定です。
見た目も、内容も不出来なプログラムですが、万一にでも、使ってくださった方の採点業務のご負担の軽減に貢献できましたなら、私にとって、それは何よりの喜びです。

プログラムのダウンロード(ZIPファイル)

プログラムのダウンロード後、任意の場所に展開してください。

【心からのお詫び】

2025年3月29日に「観点別評価と評定の整合性をチェックする」という記事を公開した際、誤ってこちらのダウンロードリンク先のファイルを削除し、そこに「観点別評価と評定の整合性をチェックするプログラム:ABC_Checker.exe」を置いてしまいました。そのため、2025年3月29日午前5時すぎから、2025年4月1日午後6時頃まで「DigitalSaiten_All_in_One.zip」がダウンロードできない状況でありましたこと、心よりお詫び申し上げます。ほんとうに、こころから、ごめんなさいです。

【もくじの前書き】

今回の記事では、採点プログラムそれぞれについて、ダウンロードしていただいたZIPファイルを展開すればすぐに試せる簡単な試用方法をご紹介しています。

実際の試験においては、スキャナーを使用してマークシートや手書き答案の画像化処理を行ったり、専用プログラムを使用して、マークシートや手書き答案の解答欄の座標を取得する等、採点の事前準備作業が必要です。

ダウンロード後展開していただいたZIPファイル内のファイルやフォルダの構成及び以下の説明の内容は、それらの必要な事前準備作業を終えた段階以降の『実際の採点作業部分のみ』を手軽にお試しいただけるように作成してあります。それぞれ、説明の通りに操作していただけたら幸いです。

操作に際し、予期しないエラーが出た場合の対処方法や、実際の試験の採点に必要な事前準備作業の詳細は、以下の説明の中でご紹介する「過去記事のご案内」リンク先の各採点プログラムの取扱い説明記事をご参照ください

【もくじ】

1.マークシートの読み取り
2.手書き答案のデジタル採点
3.マークシートと手書きを併用した試験のデジタル採点
4.採点結果通知の作成
5.お願いとお断り

1.マークシートの読み取り

プログラムアイコン


【スクリーンショット】

マーク読み取り実行直後の画面


マークシートは、市販のものでなく、再生コピー用紙にインクジェットプリンタで印刷したものを使用します。輪転機で印刷するとマークが濃く印刷されてしまい、「複数マークあり」の誤判定が出やすくなります。ですので、マークシートの印刷には、インクジェットプリンタを使用してください。

シートの左上には特徴点(例:■■■)が必要です。プログラムはマーク読み取り時に、まずシート内の特徴点を探し、そこからの距離情報をもとに一つ一つのマークを切り出して塗りつぶし面積を計算、マークの有無を判定しています。

マークシートの画像は、複合機等のスキャナーで200dpiの解像度でスキャンして作成してください。解像度を大きくしても読み取り処理に必要な時間が大幅に増加するだけでメリットは何一つありません。

デジタル採点の現場で実際に使用した様々な形式のマークシートを添付しましたので、こちらを印刷してお試しください。オリジナルマークシートの作成方法は、添付したPDFファイル「01_マークシートリーダーご利用の手引き」の「7 マークシートの作り方」をご参照ください。

【添付したマークシート】

・1列25行×4列(100設問まで対応)、選択肢は1始まりで8選択肢(A4横R25C04S08)
・1列25行×3列(75設問まで対応)、選択肢は1始まりで10選択肢(A4横R25C03S10)
・1列25行×2列(50設問まで対応)、1始まりで8選択肢。右余白は手書きの解答欄に使用。
・1列25行×3列(大問3個に対応)、16選択肢の数学用(2枚1セットで大問6個に対応)
・1列25行×3列(75設問まで対応)、選択肢は0始まりで16選択肢の教科「情報」用
・1列25行×4列(100設問まで対応)、大語群(選択肢番号は0~99まで使用可能)マーク試験用


マークシートのサンプル①

一般的な塗りつぶす形式のマークシートです。実際の試験の現場で過去5年以上使用しています。読み取り精度が問題になったことは一度もありません。

塗りつぶすマークシート(Wordで作成)


マークシートのサンプル②

線でマークすれば、大語群を使用する試験で解答に要する時間を大幅に短縮できます。ちなみに、芯の太さ0.9mm、硬さ・濃さ2Bのシャープペンシルを使用してマークし、読み取りテストを行ったところ、読み取りパラメータの設定はデフォルト値のまま、すべてのマークを正しく読むことができました。

例:線で「35」をマーク(Excelで作成)


MS_Reader.exe の詳しい使い方は、当Blogの過去記事をご参照ください。
(プログラムを動かすために必要な諸設定についての情報も記載しています)

【過去記事のご案内】

重要 数学採点用途で使用される場合は、当Blogの過去記事「マークシートリーダーを数学用に設定」にあります使用方法を必ずご確認ください。


今回掲載したプログラムには、すぐにお試しいただけますよう、マークシート情報設定済みのサンプルを添付してあります。ファイルのダウンロード及び展開に時間がかかるデメリットはありますが、マーク読み取りを圧倒的に高速化するPython4Delphi(=P4D環境)も今回は、ダウンロードサイズと展開時間を顧みず、敢えて同梱しました。ですので、ここでご紹介する筆者作のマークシートリーダーは自動的にPython環境を使用する高速読み取りモードで起動します。

以下、Zipファイルのダウンロード後、ファイルを任意のフォルダに展開した後の、筆者作マークシートリーダーの試用方法です。

(1)MS_Reader を起動

MS_Reader.exe をダブルクリックして、MS_Reader を起動します。MSはもちろんマークシートの略ですが、筆者のイニシャルが M.S なので、それにもかけてあります。

ここで発生すると思われる不具合とエラーの解決方法は、当Blogの次の過去記事をご参照ください。


(2)マークシートの情報を記録したテンプレートを選択

画面左上のメニューの「2 テンプレート」をクリックすると表示されるサブメニューの「テンプレートの選択」をクリックします。


(3)リストボックスに表示された候補から「N_R25C04S08」をクリックして選択し、「決定」ボタンをクリックします。


ちなみに、テンプレート名の N は、解像度200dpiでスキャンした際の画像ファイルの大きさをノーマルと考えて画像サイズから自動で付けています。

その後ろのR、C、D、Sはそれぞれ次のような意味です。

RはRow、すなわち「行」です。R25なら1列あたり25行のマークシートを意味します。
CはCol、すなわち「列」です。C04なら4列で構成されたマークシートを意味します。
DはDouble、複数マーク可能なマークシートを意味(19選択肢のシートのみ設定可能)。
SはSingle & Select、複数マーク不可で、S08なら選択肢の数は8個のシートを意味します。


(4)表示されるメッセージを読んで、「はい」をクリックします。


(5)「Sample_Data_01_一般用」フォルダをクリックして選択し、「OK」をクリックします。

選択するのは「フォルダ」で、「ファイル」ではありません!


(6)正しくプログラムが動作していれば、画面は次のようになります。

バックグラウンドで動作するPython環境のOpenCVが特徴点画像(■■■)を探し出し、赤枠の矩形でそれを囲んで表示します。同時に、Delphi側のプログラムでテンプレートに記録したマークシートの座標情報を読み込み、最も左側の列の第1行目の選択肢欄を赤枠で囲んで表示します。これでマークシートの読み取り準備が出来ました!


(7)操作方法を案内するバルーンが表示されますので、その先にある「読む」ボタンをクリックしてください。プログラムがマークシートのマークを読み取り、結果をグリッドコントロール上に表示します。


筆者のPCでは、Python4Delphi(P4D)を利用した状態で、1枚100設問(800マーク)×3枚で合計2400マークを986ミリ秒で読み取り、結果を表示しました。1マーク2.43ミリ秒、1枚329ミリ秒で読み取っていますので、この形式(25行×4列・8選択肢)のマークシートを使用した場合、筆者の環境では平均的な1クラス分(40名)を約13.2秒で読むものと推測できますが、使用するPCの性能によりこの値は変化します。


(8)「Check!」ボタンをクリックして、読み取り結果をヒトの目でチェックします。次の例のように、読み取り結果の確認(修正)が必要と思われる箇所で赤枠を表示してチェックプログラムは一時停止します。

【空欄(マークなし)と判定した場合】

「空欄(マークなし)」と判定した場合、グリッドコントロール上には「999」と表示されます。
なお、「白紙(全マークが空欄)」のマークシートは読み飛ばす設定が可能です。


【複数マークありと判定した場合】

「複数マークあり」と判定した場合、グリッドコントロール上には「99」と表示されます。
マークの状況を確認し、必要な場合は読み取り結果を直接入力して修正します。


読み取り結果の確認・修正後、再度「Check!」ボタンをクリックすると、一時停止が解除され、チェックが続行されます。次のメッセージが表示されたら、読み取り結果のチェックは終了です。


(9)読み取り結果の書き出しを実行

マークの読み取り結果はCSV形式でファイルに出力できます。表計算ソフトを利用して読み取り結果を処理する場合はもちろんですが、筆者が作成した「ReportCard_2024.exe」を用いて、採点結果を受験者に通知する個票を作成する場合は、必ずここで読み取り結果をCSVファイルに書き出す作業を行ってください。

読み取り結果をCSVファイルに出力


同梱の「ReportCard_2024.exe」を用いて、採点結果通知を作成できます。こちらのプログラムの使用方法は後述します。


「ReportCard_2024.exe」を用いた採点結果通知の作成例です。

設問ごとに採点結果と観点別評価の区分及び正解マークを表示できます。
また、任意の位置に得点を表示できます。


(10)その他の機能

MS_Reader には、マーク読み取りに加え、読み取り結果の音声読み上げ機能や、チェックのスキップ機能など、筆者が必要と考えた機能を搭載してあります。使い方の詳細は、当Blogの過去記事(上のリンク先)にありますので、必要に応じてご参照ください。

2.手書き答案のデジタル採点

プログラムアイコン


【スクリーンショット】

横書き答案の採点実行時の画面
(添付した答案枚数3枚の採点サンプルを使用)


採点する答案は、横書き・縦書きを問いません。どちらの形式の答案でも採点可能です。また、複合機のスキャナーで読み取り可能な大きさであれば、解答用紙のサイズも問いません(ただし、採点する答案すべてのサイズと解答欄の形式は同じである必要があります)。前述のマークシートでは、解答用紙の左上に特徴点(■■■)が必要でしたが、こちらの手書き答案の採点補助プログラムでは必要ありません。

答案画像は複合機のスキャナー等を用いて、解像度200dpiでスキャンしたカラー画像を使用してください。解答欄矩形は、採点準備作業時に、別に作成した矩形検出プログラムを用いて解答用紙画像より検出・座標データを取得してiniファイルに保存します。手書き答案の採点プログラムは、この座標データをもとに解答欄矩形を答案画像から切り出して、切り出した解答欄を画面に並べて表示します。

解答用紙の解答欄を作成する際は、矩形検出されたくない部分を点線で作成していただく必要がありますが、これさえ守っていただければ、かなりスムーズに解答欄矩形の検出作業(座標データ化)が行えると思います。実際に採点に入るまでに必要な採点準備作業の詳細は、当Blogの過去記事をご参照ください。

解答欄の作成例:矩形検出されたくない部分の罫線は点線を使用します。
これにより設問番号を含んだ解答欄矩形の切り出しや、
字数を指定しての解答欄作成が可能になります。


【過去記事のご案内】

【機能追加版のご案内】

ここで紹介している手書き答案のデジタル採点補助プログラム AC_Reader に自動採点機能みたいなモノを搭載しました。


今回掲載したプログラムには、すぐにお試しいただけますよう、採点準備作業を行ってあるサンプルを添付してあります。以下、手書き答案採点補助プログラムの試用方法です。

(1)AC_Readerを起動

AC_Reader.exe をダブルクリックしてプログラムを起動します。AC は Answer Column(解答欄)の略です。プログラム起動時(初回)に次のメッセージが表示されます。

PCのボリューム設定値が0より大きい場合に表示されるメッセージです。

消音して作業できます。
(ボリューム設定値が0の場合は、表示されません)


採点作業内容の確認メッセージです。

必要に応じて「はい」・「いいえ」のいずれかをクリックします。


「いいえ」をクリックすると、次のメッセージが表示されますが、これは表計算ソフトを使用して採点結果通知を作成していた頃の名残りで、表計算ソフトを使用せずに採点結果通知の作成ができるようになった現在はどちらから採点を始めていただいても問題は生じません。


試用される場合、次のメッセージには「いいえ」を選択(クリック)してください。

添付した採点試行用のサンプルは「横書き」です。


初回起動時には複数のメッセージが表示されますが、次回起動時からこれを表示しない設定にすることができます。お好きな方のボタンをクリックしてください。


あらゆるケースを想定した場合、このようなメッセージも必要と判断しました・・・。
(確か、採点結果通知の個票作成プログラムで、合計点を計算するコードを書いていた際に「何か」問題が起きて、このメッセージを表示することにしたような記憶があります)


お断りしたように不出来なプログラムですので、こちらの注意も必ずお守りください。


同じく、こちらの注意も必ずお守りください。


採点方法のご案内です。複数のユーザーより、「前回の採点から2~3か月も経過すると忘れてしまう!」との指摘がありましたので起動時に採点方法を案内するメッセージを表示するようにしました。


このメッセージは、プログラムの画面右下にある「入力方法のご案内」ボタンをクリックすれば、いつでも表示することができます。


(2)既存の採点設定を選択

試用に際しては、筆者が設定・保存した採点設定をお使いください。
画面右上にある「採点作業」ボタンをクリックします。


次のメッセージが表示されますので、「はい」をクリックしてください。


採点設定ファイルの選択を促す案内バルーンが表示されます。
ComboBox右端の∨をクリックしてください。


表示された選択肢の「テスト採点.ini」をクリックして選択します。


(3)続けて採点したいクラス/講座の答案画像が保存されているフォルダを選択します。


上のメッセージの「OK」をクリックすると、フォルダの選択ダイアログが表示されます。

「Sample_Data_04_Markと横手書併用」フォルダをクリックして、「OK」をクリック


(4)採点を実行

最初にフローティング状態のパネルを適切な位置へ移動します。

フローティングパネルのタイトルバーをクリックして任意の位置へD&Dします。


点数を一括入力する場合は、「入力と確認」のComboBoxから入力したい値を選択して「入力」ボタンをクリックします。選択した値がすべての解答欄に設定されますが、入力値が「0」であれば×、そうでない場合は○と得点が表示されます。


個々の採点は、採点したい解答欄の中央付近をクリックして、採点方法の案内にあった方法で採点します。


(5)採点結果の保存

採点結果を答案画像に書き込むには、フローティングパネルの「書込」ボタンをクリックします。

重要 作業の状態は「書込」ボタンをクリックしたところまでが保存されます。「書込」ボタンをクリック後はいつでも終了できます。

重要 採点は何度でもやり直すことができます。


何設問目まで採点したかについては、答案画像を表示して確認できます。

「返却用答案を表示」にチェックを入れると、現在採点している答案画像が表示されます。


◀ボタンや▶ボタンをクリックして表示する答案を変更することができます。


(6)返却用答案及び成績一覧表の作成

手書き答案の採点プログラムには、単独で受験者に返却する答案の印刷や教科担任用の成績一覧表を作成する機能があったのですが、今回、新しく採点結果通知作成プログラムを作成しましたので、独自に返却用答案を作成する機能はCut(正確には非表示に)してあります。

今回、新しく作成した採点結果を通知する個票及び教科・科目担任用に成績一覧表を作成するプログラム「ReportCard_2024」は、「返却用答案&成績一覧作成」をクリックすると起動できます。


「ReportCard_2024」の使い方は、この後の説明をお読みください。

3.マークシートと手書きを併用した試験のデジタル採点

次のような解答用紙を用いて、マークシートと手書きを併用した試験を実施・採点することも可能です。


採点は、マークシート部分のマークの読み取りはMS_Readerで、手書き解答欄の採点はAC_Readerでそれぞれ行ってください。

MS_Readerを用いてマークの読み取りを行った後はCSVファイルに読み取り結果を出力、AC_Readerを用いて手書き答案の採点を行った後は採点結果を「書込み」ボタンをクリックして自動保存(保存先ファイル等を指定していただく必要はありません)していただければ、採点結果通知の作成準備も内部的に完了します。

どちらの採点を先に行うかについて、その作業順は問いませんが、マークシートの採点→手書き答案の採点という流れの方がプログラムが表示するメッセージの内容に矛盾を感じることなく作業できると思います。

採点終了後、次にご案内する採点結果通知を作成するプログラムで、それぞれの試験の合計得点を計算します。

4.採点結果通知の作成

プログラムアイコン

【スクリーンショット①】

受験者への採点結果通知の作成例


【スクリーンショット②】

採点者用の成績一覧表の作成例
氏名データは架空のものです。また、得点データが2件しかないのは添付した試用サンプルを用いて作成したためです。

ReportCard_2024の使い方

(1)起動

ReportCard_2024.exe をダブルクリックしてプログラムを起動します。PCのボリューム設定値が0でない場合は、Beep音を消音するかどうかを確認するメッセージが表示されます。


(2)採点作業を選択します。

ここでは併用タイプを選択しました。


(3)採点対象の答案画像を保存したフォルダを選択します。

「開く」ボタンをクリックします。


採点対象の答案画像を保存したフォルダを選択します。


(4)採点設定がある場合

既存の採点設定がGridコントロールに表示され、採点できる状態になります。

「実行」ボタンをクリックして採点を行ってください。


マークシートの採点の場合、形式の確認メッセージが表示されます。


マークシートの採点の場合、使用したテンプレートを指定します。


採点結果は次のように表示されます。

【マークシート部分①】

デフォルト設定では、左から「採点記号・配点・観点別評価の区分」がそれぞれ表示されます。


【マークシート部分②】

空欄もしくは誤りがある設問には正解の選択肢が数字で示されます(デフォルト設定を利用した場合)。


【手書き答案部分】

観点別評価の区分を表示することはできませんので、受験者に口頭で区分を説明する必要があります。

得点は下の例のように表示されます。得点の表示位置は任意の位置を選択・設定を保存できます。

手書き答案部分には「採点記号と得点」が表示されます。
(観点別評価の区分を表示することはできません)


(5)採点設定がない場合

次のメッセージが表示されます。内容をよく読んで「OK」をクリックしてください。


設問数を入力し、「入力完了」をチェックしてください。


操作方法を案内するメッセージが表示されます。


入力をクリックします。


デフォルトの配点を設定します。入力は半角数字で整数を入力し、「OK」をクリックしてください。


マークシート用の採点設定には「正解の選択肢の番号」・「配点」・「観点別評価の区分」をそれぞれ入力してください。また、手書き答案用の採点設定には「配点」・「観点別評価の区分」を入力してください。


採点設定を入力後、「保存」ボタンをクリックして、設定を保存してください。


採点設定の保存が完了すると、採点の「実行」ボタンがクリックできる状態になります。


(6)採点結果通知個票の印刷

「印刷」ボタンをクリックしてください。


出力先プリンタ・用紙・印刷の向きを指定して「OK」をクリックしてください。
元々の答案のサイズがA3やB4であっても、用紙サイズでA4を指定すれば縮小印刷されます。


答案すべてを印刷するか、個別に印刷するか、いずれかを指定してください。


「いいえ」(個別印刷を選択)を選んだ場合は、印刷したい答案の番号を指定してください。


印刷例です。


(7)成績一覧表の作成と印刷

成績一覧表の作成の「講座名票」ボタンをクリックします。


受験者の氏名等のデータを保存したCSVファイルを選択します。

予めsNameフォルダ内にクラス・講座の氏名データを所定の様式で作成・保存してください。


【参考:氏名データの様式】

クラス・出席番号は「半角」で入力、氏名・よみがな・性別は「全角」でそれぞれ入力し、CSV形式でsNameフォルダ内に保存してください。

フィールド名は入れないでください。
(添付したデータは架空のものです)


平均点を正しく計算するため、試験を欠席した受験者を計算対象から除く処理を行います。
「欠席者をチェック」のCheckBoxをチェックしてください。


得点の合計が「0」の受験者について、平均点の計算処理の対象とするか・しないかを指定します。「はい」をクリックした場合は、成績は「空欄」扱いとなり、平均点の計算対象からは除かれます。
「いいえ」をクリックした場合は、その受験者の得点合計は0点であったものとして平均点を計算します。


印刷プレビューを表示します。「PreView」ボタンをクリックしてください。


添付したファイルのデータはすべて架空のものです。

添付したサンプルデータが3件しなないため、このような表示となっています。


「印刷」ボタンをクリックして印刷します。「CSV出力」ボタンをクリックすれば、CSVファイルに出力することも可能です。表計算ソフトを利用した追加の処理にお役立てください。


CSVファイルのファイル名は自動的に設定されます。また、CSVファイルは処理対象の答案画像があるフォルダ内に出力されます。

「保存」ボタンをクリックしてください。


保存処理が完了すると、次のメッセージが表示されます。


「はい」をクリックした場合、エクスプローラーが起動し、保存先フォルダを開きます。
「いいえ」をクリックした場合は、CSVファイルの保存先を示すメッセージが表示されます。

「はい」をクリックした場合、エクスプローラーが起動し、保存先フォルダを開きます。

5.お願いとお断り

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

追記(20250702)

100選択肢用のマークシートを使って試験を行ったユーザーの方から、『マークの塗りつぶし面積が大きいと誤判定が出やすい』とのご指摘をいただきました。確認すると、受験者は「濃く・丁寧に」マークを塗りつぶしたことがマークシートから伝わってくるのですが、100選択肢用のA4横サイズのマークシートは・・・やはり、と言うか、どうしてもマークそのものが小さく、受験者によっては「きちんとマークすること=大きくマークすること」と、感じて(?)、選択肢番号の外枠の楕円「0」部分を上下左右に大きくはみ出して隣のマークの領域範囲まで塗りつぶしてしまい、結果的に、その受験者のマークシートは複数マーク判定だらけになることがわかりました。

対策として、『選択肢番号の外枠の楕円「0」部分をはみ出さないようにマークする』よう注意を徹底することをお願いしたのですが、それだけでは根本的な解決とならないように感じ、マークとマークの間隔が狭いためにこの問題が起きていることは明白ですから、1設問について100選択肢に対応を維持しつつ、1列25行×4列で100設問まで対応という現在のマークシート構成を見直し(マーク間の幅を広げるため列数を減らし)、1列33行×3列で99設問まで対応可能というマークシートを作成しました。また、50分という試験時間を考えると80設問あれば十分というご意見も頂戴しましたので、1列30行×3列で90設問まで対応可能なマークシートや、1列25行×3列で75設問まで対応可能なマークシートを作成し、これらのマークシートを1つの Excel Book にまとめました。以下のリンクからダウンロードできます。

[dcc32 致命的エラー] F2039 ファイル ‘.\Win32\Release\project1.exe’ を作成できません


Delphiで「実行(F9)」すると、時々、コレが・・・

コンパイル結果の表示

さらに・・・

メッセージにも(泣)


原因は人それぞれなのかもしれませんが、私は以下の方法でこれを解決できました!

【これまでの解決方法】

既存の「xxx.exe」を手動で削除して、再度「実行(F9)」。
今までは、ずっとこの方法で対応。

【新しい解決方法】

「プロジェクト」ツールウィンドウの xxx.exe を右クリックして、


表示されるメニューの「クリーンアップ」をクリック。


「クリーンアップ」というタイトルの画面が表示され・・・


「成功」!


これで「実行(F9)」で、再び exe が生成されるようになりました。

【まとめ】

「実行(F9)」時に exe を作成できない場合は、プロジェクトツールウインドウの exe を右クリックして「クリーンアップ」を実行する。

【お願いとお断り】

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

StringGridのデータを印刷プレビューして印刷

Delphiで、どうしても書きたいプログラムがあった。
そのために絶対に越えなければならないハードルが、データの印刷プレビューと印刷だ。

正直、これは、ごく簡単に思えてならないことなんだけれど・・・
今まで何度もチャレンジして、そのたびに失敗してきた。。。

今回、ようやく、自分自身、納得の行くものが書けた。
これはその備忘録。

【もくじ】

1.印刷したいデータを準備
2.罫線も印刷する
3.CSVファイルをStringGridに表示する
4.印刷のコード
5.rect:TRectとしてはいけません!
6.印刷プレビューのコード
7.まとめ
8.お願いとお断り

1.印刷したいデータを準備

表計算ソフトを使って、印刷したいデータを作成し、CSV形式で保存する。
例えば、こんな感じ。

データはすべて架空のもの


書きたい印刷プログラムは、A4版・縦の用紙に収まる範囲の列数で、行数は1ページに最高50行を予定。ただし、データとして50行なので、フィールド名を入れれば1ページあたり51行となる。

CSVファイルの先頭には「フィールド名も保存」する。ただし、フィールド名があるのはファイルの先頭のみ。50行ごとに入れたりはしない。

自分の場合、印刷データが50行を超えることは、まず「ない」・・・のだが、冒頭で述べた「どうしても書きたいプログラム」の使用予定者の中にはそうでない方もいる。

なので、書きたいプログラムでは、データ数(=行数)がどんなに増えても、各ページの先頭行にはフィールド名を入れる仕様とする。これは譲れない自分との約束。

2.罫線も印刷する

これも、どうしても越えたいハードルのひとつ。フォントの大きさに関係なく、1行毎に罫線(=下線)を印刷する。もちろん、「罫線無し」の印刷も可能とするが、「罫線有り」の場合は1ページについて必ず51行分の罫線が引かれるのではなく、印刷する行数に合わせて罫線(=下線)を引くようにしたい。

今まで、これがどうしても「できなかった」。

だから、この壁は必ず乗り越える。これも譲れない自分との約束。


追記(20240901)

フォントの大きさは9ポイントに固定しました。

3.CSVファイルをStringGridに表示する

Delphiを起動。次の構造ペインに示すような形でVCLコントロールをForm上に配置。

最低限必要なVCLコントロール
AlignやVisibleなど、各プロパティは必要に応じて設定


Formは常に最大化して表示されるように設定。FormCreate手続きに次のコードを記述。

procedure TForm1.FormCreate(Sender: TObject);
begin
  //Formを最大化して表示(幅も最大化される)
  Form1.WindowState:=wsMaximized;
end;

この場合は、Form1が親だからこれでOKだが、子の場合は注意が必要。

どこに記述するか?
・自分自身が親Formの場合:FormCreateでOK!
・自分自身が子Formの場合:FormShowに書くこと(FormCreateに書くと一般保護違反のエラーが発生する)
・自分自身が子Formの場合にFormのWindowStateプロパティで直接指定しておいたらMy環境では何の問題もなく動作した。

また、Form1のScaledプロパティをFalseに設定することも忘れない。

これはどんなプログラムでも必ず最初に設定する


これをTrue にすると OS の DPI (ユーザが指定した DPI)によってフォームサイズやコントロールサイズが勝手に変更されてしまう。デフォルトでTrueなので注意が必要。Formを作成したら毎回忘れずに設定する。

で、exeがあるフォルダ以下の構成は次の通り。この階層構造をもとにしてPathを設定する。

ProcはProceed(処理済み)の略(のつもり)


StringGridに読み込むCSVファイルの置き場所は、「\sNameフォルダ」とする。

sNameフォルダ内にCSVファイルを用意する


これを読み込んでStringGridに表示する。OpenDialogをFormに追加する。

Form上に追加


読み込むCSVファイルのデータは次の通り。
(フィールド名が「ない」場合や、データに通し番号がなく、行番号を表示したい場合にも対応できるコードを含めて記述した。必要ない部分はコメントアウトしている)

今回使用するデータは「フィールド名・通し番号あり」


次のコードを記述。

procedure TForm1.Button1Click(Sender: TObject);
var
  //CSVファイルの読み込み
  CSVFileName: string;
  CsvFile:TextFile;
  CsvRowStr: string;
  i: Integer;
  strMsg: string;
  //列幅の調整
  iCOL: Integer;
  MaxColWidth: Integer;
  iROW: Integer;
  TmpColWidth: Integer;
begin

  //表示設定
  StringGrid1.Visible:=False;

  //列数
  StringGrid1.ColCount:=7;

  //OpenDialogのプロパティはExecuteする前に設定しておくこと
  With OpenDialog1 do begin
    //表示するファイルの種類をcsvに設定
    Filter:='CSVファイル(*.csv)|*.csv';
    //データの読込先フォルダを指定
    InitialDir:=ExtractFilePath(Application.ExeName)+'sName';
  end;

  //ダイアログ呼び出し
  if OpenDialog1.Execute then
  begin
    CsvFileName:=OpenDialog1.FileName;
    AssignFile(CsvFile, CsvFileName);
    Reset(CsvFile);
  end else begin
    strMsg:='ユーザーによる処理のキャンセル';
    Application.MessageBox(PChar(strMsg), PChar('情報'), MB_ICONINFORMATION);
    Exit;
  end;

  //フィールド名が必要なCSVファイルなら記述する
  //StringGrid1.Rows[0].CommaText:=
  //  '通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';
  //Fixed Colが1列あって、そこに行番号を設定する場合
  //  ',通し番号,氏名,よみがな,年齢,生年月日,性別,血液型';

  //読込み開始行を指定(FixedRowがある場合 -> ない場合は[0]にする)
  i:=0;
  try
    while not EOF(CsvFile) do
    begin
      //CSVファイルを1行読み込み、その1行分を文字列として代入する。
      Readln(CsvFile, CsvRowStr);
      //グリッドの行数が読み込み行数より少なければ、グリッドの行数を追加する。
      if StringGrid1.RowCount <= i then StringGrid1.RowCount := i + 1;
      //グリッドの指定行目に読み込み行を代入
      //[0]列はFixedCol-> 行番号を設定したい場合
      //StringGrid1.Rows[i].CommaText:=IntToStr(i)+','+CsvRowStr;
      StringGrid1.Rows[i].CommaText:=CsvRowStr;
      i := i + 1;
    end;
  finally
    //行番号を設定した場合
    //StringGrid1.Cells[0,0]:='行番号';
    CloseFile(CsvFile);
  end;

  //列幅の自動調整
  for iCOL := 0 to StringGrid1.ColCount-1 do
  begin
    MaxColWidth := 0;
    for iROW := 0 to StringGrid1.RowCount-1 do
    begin
      TmpColWidth := Canvas.TextWidth(StringGrid1.Cells[iCOL,iROW]) + 10;
      if MaxColWidth < TmpColWidth then
        MaxColWidth := TmpColWidth;
    end;
    StringGrid1.ColWidths[iCOL] := MaxColWidth;
  end;

  //表示設定
  StringGrid1.Visible:=True;

end;

実行結果は次の通り。

文字コードはANSI(CP932、Shift_JIS が拡張されたもの?)


以下、データを読み込む上での注意のあれこれ。

まず、CSVファイルの文字コードがUTF-8だと・・・

たいへんなコトに・・・


また、氏名やよみがなの「姓と名の間にあるスペースが全角でなく、半角」だと・・・

このコードでは、半角スペースが区切り文字として認識されてしまう!


CSVファイルのデータ形式には、文字コードも含めて十分、注意する必要がある。

4.印刷のコード

正直に言うと、今回のチャレンジでは「印刷プレビュー」のプログラムの方を先に書いた。
自分自身の感覚に自信などあるわけないが、通常(?)の感覚からすれば「印刷プレビュー」⇨「印刷」という流れが自然であるような気がして、そうなったのだ。

そこで問題になったのがデータ数が多く、印刷(出力)が「複数ページ」となる場合、プレビューの2枚目、3枚目をどう表示するか? という部分。

先に述べた通り、2枚目以降の先頭行にも「フィールド名を表示」するという自分との約束もあったし・・・。この「ページ毎、先頭行にはフィールド名を表示する」処理の方法をいろいろ考え、試してみたが、どうにも上手く行かない。

これだ! と思える処理手順が思いつかないまま、1ページ目だけの表示であれば問題なくできるプログラムを作成。とりあえず、印刷プレビューは1ページ目だけ表示することで妥協して、(仮)印刷プレビュープログラムとしておき、複数ページの印刷に対応した印刷プログラムが完成したら、もう一度、夢見た通りの印刷プレビューとなるよう、ここに戻ってくることにする。

そうして様々な問題をひとつひとつ自分なりに丁寧にクリアして最終的に書き上げたのが、下に掲載した「印刷」のプログラムコード(データ全体の行数や列数は限定せず、汎用的に使える=再利用できるコードを目指したつもりだが、どうだろうか?)。

FormにPrinterSetupDialog、その他のVCLコントロールを追加。

PrinterSetupDialogの方が用紙サイズと印刷方向を選ぶには便利!


if PrinterSetupDialog1.Execute then ~で、呼び出したDialogの画面。

今回の用途なら、出力先のプリンタ・用紙・向きの指定が出来ればOK!
追加したVCLコントロールとそれらに設定した名称
設定した値と状態

Button2は「印刷プレビュー」機能を、Button3は「印刷」機能を、それぞれ割り当てる予定。
なので、Button3をダブルクリックして手続きを作成し、「印刷」プログラムのコードを入力。

implementation

uses
  System.Math,
  Vcl.Printers;
procedure TForm1.Button3Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値 -> 汎用性を考えExtended ではなく、Double とした
  DSum: Double;
  DAvg: Double;
  MinValue, MaxValue: Double;
  intDenomin: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  const
    InfoSize = SizeOf(TBitmapInfoHeader) + 4 * 256;
  var
    OldMode   : integer;      // StretchModeの保存用
    pInfo     : PBitmapInfo;  // DIBヘッダ+カラーテーブルへのポインタ

    InfoData  : array[0..InfoSize-1] of Byte; // DIBヘッダ+カラーテーブル
    Image     : array of Byte;// DIBのピクセルデータ
    DC        : HDC;          // GetDIBits 用 Device Context
    OldPal    : HPALETTE;     // パレット保存用
  begin
    pInfo :=@InfoData;

    // 24 Bit DIB の領域を確保
    SetLength(Image, ((Bitmap.Width * 24 + 31) div 32) * 4 * Bitmap.Height);

    // DIB のBitmapInfoHeader を初期化
    with pInfo^.bmiHeader do begin
      biSize := SizeOf(TBitmapInfoHeader);
      biWidth := Bitmap.Width;     biHeight := Bitmap.Height;
      biPlanes := 1;               biBitCount := 24;
      biCompression := BI_RGB;
    end;

    // 24bpp DIB イメージを取得
    DC := GetDC(0);
    try
      OldPal := 0;
      if Bitmap.Palette <> 0 then
        OldPal := SelectPalette(DC, Bitmap.Palette, True);

      GetDIBits(DC, Bitmap.Handle, 0, Bitmap.Height,
                Image, pInfo^, DIB_RGB_COLORS);
      if OldPal <> 0 then SelectPalette(DC, OldPal, True);
    finally
      ReleaseDC(0, DC);
    end;

    // 拡大モードを カラー用に変更
    OldMode:=SetStretchBltMode(Canvas.Handle,COLORONCOLOR);

    // 描画!!
    StretchDIBits(Canvas.Handle,
                  r.Left,r.Top,r.Right-r.Left,r.Bottom-r.Top,
                  0,0,pInfo^.bmiHeader.biWidth,pInfo^.bmiHeader.biHeight,
                  Image,pInfo^,DIB_RGB_COLORS,SRCCOPY);
    // 拡大モードを元に戻す
    SetStretchBltMode(Canvas.Handle,OldMode);
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

begin

  //複数回クリックを防止する
  Button3.Enabled:=False;

  //初期化
  Image1.Picture:=nil;
  Image2.Picture:=nil;
  Image1.Visible:=False;
  Image2.Visible:=False;

  //印刷設定(用紙・向き)後に印刷
  if PrinterSetupDialog1.Execute then
  begin

    //プリンタの設定を取得
    Printer.GetPrinter(Device, Driver, Port, DeviceMode);
    DevMode := GlobalLock(DeviceMode);
    try
      //用紙サイズをA4に設定
      DevMode^.dmPaperSize := DMPAPER_A4;
      //用紙方向を縦に設定
      DevMode^.dmOrientation := DMORIENT_PORTRAIT;
      //設定をプリンタに反映
      Printer.SetPrinter(Device, Driver, Port, DeviceMode);
    finally
      GlobalUnlock(DeviceMode);
    end;

    //プリンタの解像度を取得
    //DPI := GetDeviceCaps(Printer.Handle, LOGPIXELSX);
    //家庭用のEPSONのプリンタは360DPI
    //業務用のEPSONの複合機は、600DPI
    //FinePrintは、600DPI

    //A4サイズの用紙の寸法は210mm x 297mm。インチに換算:約8.27インチ x 11.69インチ
    //プリンタの解像度(DPI: Dots Per Inch)

    //100DPIの場合
    //幅: 8.27インチ × 100 DPI = 827ピクセル
    //高さ: 11.69インチ × 100 DPI = 1169ピクセル

    //200DPIの場合
    //幅: 8.27インチ × 200 DPI = 1654ピクセル
    //高さ: 11.69インチ × 200 DPI = 2338ピクセル

    //100DPIとして描画したものをStretchDrawする

    //TImageの初期設定
    Image1.Width := 827;
    Image1.Height := 1169;
    Image1.Picture.Bitmap.Width := 827;
    Image1.Picture.Bitmap.Height := 1169;

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    MyRect:=Rect(0, 0, 827, 1169);
    Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

    //使用するフォント(必ず等幅フォントを指定する)
    //数値の右揃え用に追加(20240820)
    Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

    //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
    Image1.Picture.Bitmap.Canvas.Font.Size:=11;

    //平均値を計算 -> 実際のプログラムではこのような計算も行っている
    {
    DSum:=0;
    for i := 1 to StringGrid1.RowCount do
    begin
      if StringGrid1.Cells[5,i] <> '' then
      begin
        DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
      end;
    end;
    DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

    //最高値及び最低値を計算
    GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);
    }

    //StringGrid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを追加している。
    //追加したデータを含めて印刷する仕様

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

    StringList:=TStringList.Create;
    Fields:=TStringList.Create;

    try

      //Create
      StringList.LoadFromFile(csvFN);
      //Create
      SetLength(FieldWidths, 0);

      //各フィールドの最大幅を計算
      for i := 0 to StringList.Count - 1 do
      begin
        Fields.CommaText := StringList[i];
        if Length(FieldWidths) < Fields.Count then
          SetLength(FieldWidths, Fields.Count);

        for j := 0 to Fields.Count - 1 do
        begin
          //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
          MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
          if FieldWidths[j] < MaxWidth then
            FieldWidths[j] := MaxWidth;
        end;
      end;

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を挿入(追加)
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;

      //ここから印刷Loop
      try

        for intLoop := 0 to eNum do
        begin

          //初期化(白紙にする)
          Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
          MyRect:=Rect(0, 0, 827, 1169);
          Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

          if intLoop=0 then
          begin
            Printer.BeginDoc;
          end else begin
            Printer.NewPage;
          end;

          //タイトルを描画
          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption);

          //タイトルを描画
          {
          Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
          Image1.Picture.Bitmap.Canvas.TextOut(
            StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
            LabelSaveFolderName.Caption + ' 【平均値:'+FloatToStr(DAvg)+
            '、最高値:'+ FloatToStr(MaxValue)+
            '、最低値:'+ FloatToStr(MinValue)+'】');
          }

          Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

          k:=0;
          MarginX:=StrToInt(EditMarginX.Text);
          MarginY:=StrToInt(EditMarginY.Text);
          ColMargin:=StrToInt(EditColMargin.Text);

          iPlus:=0;
          //次のcase文でelseを使って何らかの値が必ず代入されるようにしたので不要
          //LowNum:=0;
          //HighNum:=0;

          case intLoop of
            0:begin
              LowNum:=0;
              if StringList.Count > 50 then
              begin
                HighNum:=50;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            {
            1:begin
              LowNum:=51;
              if StringList.Count > 100 then
              begin
                HighNum:=100;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            2:begin
              LowNum:=101;
              if StringList.Count > 150 then
              begin
                HighNum:=150;
              end else begin
                HighNum:=StringList.Count-1;
              end;
            end;
            }
          else
            //一般化
            LowNum:=(intLoop*50)+1;
            if StringList.Count > (intLoop*50)+50 then
            begin
              HighNum:=(intLoop*50)+50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;

          for i := LowNum to HighNum do
          begin
            Fields.CommaText := StringList[i];
            for j := 0 to Fields.Count - 1 do
            begin
              //処理できる列数を無制限にする
              case j of
                0:k:=0;
              else
                k:=k+FieldWidths[j-1]+ColMargin;
              end;
              //フィールド名に「備考」を追加する
              if i=0 then
              begin
                if j=Fields.Count-1 then
                begin
                  Fields[j]:=Fields[j]+' 備考';
                end;
              end;

              //データを出力(数値の右揃え:なし)
              //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

              //データを出力(数値の右揃え:あり))
              //数値の右揃え用に追加(20240820
              if TryStrToInt(Fields[j], intValue) then
              begin
                //数値である -> 右揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                  Format('%3d', [strToInt(Fields[j])]));
              end else begin
                //数値でない -> 左揃えで出力する
                Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
              end;

              //罫線を描画
              if cbLine.Checked then
              begin
                Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
                FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
                Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
                Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
              end;
            end;
            inc(iPlus);
          end;

          //大きさを指定
          MyRect.Top:=0;
          MyRect.Left:=0;
          MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          MyRect.Right:= Printer.PageWidth;
          //ファイルを描画
          StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
          Application.ProcessMessages;

        end;  //intLoop

      finally
        Printer.EndDoc;
      end;

    finally
      StringList.Free;
      Fields.Free;
      //複数回クリックを防止する
      Button3.Enabled:=True;
    end;

    //ファイルの完全削除
    DeleteFile(PChar(csvFN));

    //TImageの表示位置を指定
    ScrollBox1.VertScrollBar.Position:=0;
    ScrollBox1.HorzScrollBar.Position:=0;
    Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
    Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;

    //TImageの表示
    Image1.Visible:=True;  //印刷プレビューを実行しなければ不要

    Button2Click(Sender);  //印刷プレビューを表示する

  end else begin
    //キャンセルに対応
    Button2Click(Sender);  //印刷プレビューを表示する
  end;

end;

上のコードには、このサンプルでの処理には不要な部分や、(コードを一般化する前に場合分けして処理手順を考えた)冗長な部分も含まれている。自分のバカさを全世界にPRするようなものだが、計算処理を追加したり、コードを一般化する際の考え方の参考となるよう、敢えてそのまま残した。

そもそも、この記事を書こうと思ったきっかけは、DelphiのStringGridの内容を定型用紙に印刷するサンプルコードが(Web上に)あまりにも少ない気がしたこと。ただ、この例では、いったんCSVファイルにして保存したり、PrinterのCanvasではなくTImageのCanvasに描画したり、普通とは言い難い方法を行って印刷している気がするので、普通(?)の印刷方法を学びたい方にはまるで参考にならないかもですが、万一にでも、どなたかのお役に立てれば何よりの幸いです。

【実行結果】

まず、PrinterSetupDialogが表示されるので、A4・縦を選択する。

プリンター名に「FinePrint」とあるのはお気に入りのプリントユーティリティ
プレビュー的に印刷内容を確認したり、まとめ印刷を行ったり、縮小・両面設定で印刷枚数を減らしたり、とにかく使えるユーティリティ
FinePrintへ出力
FinePrintへの出力の印刷プレビュー部分を拡大


これで数値データを右寄せ表示できれば、大満足なんだけど・・・ 。

追記(20240819)

Format関数を使えば、数値データの右揃えが簡単に実現できることを忘れてた!

(最近は「データを保存する」プログラムばかり書いていて、「データを印刷する」プログラムはほとんど書いたことがないことにあらためて気づいた。

 例1:csv形式で保存 -> 表計算ソフトで読み込んで活用。
 例2:表計算ソフトのファイルにADO接続して、直接書き込み。
 例3:データベースにADO接続して、データを保存、必要な部分をクエリで抽出。みたいな・・・

遠い昔、VBでデータを縦・横罫線付きの一覧表形式で印刷するプログラムをさんざん書いていたのが夢のよう・・・。

てか、今回も最終的に印刷しているのは、プリンターのCanvasに描画した「絵」なんですが。)

「印刷プレビュー」及び「印刷」の手続きを次のように追加・修正。

1.手続きの冒頭で、「等幅フォントを忘れずに指定」する(追加)。

  //使用するフォント(必ず等幅フォントを指定する)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ -> 実際にはComboBoxで指定・選択できるようにする
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;

2.データ出力部分を次のように修正する。

  //データを出力
  //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

  //数値データは右揃えで出力する
  if TryStrToInt(Fields[j], intValue) then
  begin
    //数値である -> 右揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
      Format('%3d', [strToInt(Fields[j])]));
  end else begin
    //数値でない -> 左揃えで出力する
    Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
  end;

【実行結果】

通し番号(と年齢)が右揃えになった!

追記(20240819)ここまで

2ページ目以降も先頭行にフィールド名を表示

今までの自分には罫線付きでこのように表示することが、どうしてもできなかった・・・


先頭行にフィールド名を表示する部分は、いちばん悩んだところ。
最終的に変数eNum(LoopのEndNumber)から印刷に必要なページ数を取得し、StringListに格納した印刷データの0番目の要素をコピーして、これをStringListの51、101、151のように、eNumの現在の値( i * 50)+1番目に挿入して行く方法が計算的にも、処理的にも、いちばんラクなのではないか?・・・と考え、このアルゴリズムでプログラムを作成。

こうすれば1ページ目には要素0から50、2ページ目には要素51から100、3ページ目には101から150・・・のように印刷データの割り当てが決まり、プログラムも心もすっきり。0番目の要素(フィールド名)のコピーと所定の位置への挿入さえ行ってしまえば、あとは単純にLoopを廻すだけだ。

      eNum:=StringList.Count div 50;

      //51,101,151,201,251,301・・・番目にフィールド名を挿入

      //0番目の要素をコピー
      myFieldElement:=StringList[0];
      //要素を追加
      if eNum<>0 then
      begin
        for i := 1 to eNum do
        begin
          StringList.Insert((50*i)+1, myFieldElement);
        end;
      end;


最も重要なデータ出力部分は、生成AIに教えてもらった!
次のデータを出力するコードの(iPlus * 20)の部分は、自分では絶対に書けなかったと思う・・・。
ついに、と言うか、とうとう、わからない部分は生成AIに聞きながらプログラムが書ける、夢のような時代がやってきた!!

でも、頼りすぎは禁物。実際、今回もかなり痛い目にあった・・・。
その内容は、後述。

//データを出力
Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

印刷枚数が1ページだけなら、変数はLoop変数の i がそのまま使えるのだが、複数枚印刷を実行する必要があるので、Loop用の変数 i とは別に iPlus という名前の変数(特に意味はない)を用意し、ページが切り替わる毎にゼロで初期化するように生成AIが教えてくれたプログラムを改良。

それから、これはあった方が親切かな? と考え、先頭行のフィールド名の最後に「備考」も追加。

Loopの様子をわかりやすくしたのが次のコード。
ページ内のデータ印刷作業で、i , j , k を使ってしまったので、いちばん大きな(外側の)ページを切り替えるLoopの変数名をどうするかで悩み l(エルの小文字)はちょっと・・・って感じがしたので、最終的に変数名はintLoopとした。

最終的に、TImageのBitmapに出力したものをPrinterのCanvasにコピーして印刷している。

for intLoop := 0 to eNum do
begin
  k:=0;
  iPlus:=0;
  for i := LowNum to HighNum do
  begin
    for j := 0 to Fields.Count - 1 do
    begin    
      //フィールド名に「備考」を追加する
      if i=0 then
      begin
        if j=Fields.Count-1 then
        begin
          Fields[j]:=Fields[j]+' 備考';
        end;
      end;
      //データを出力
      Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
    end;
    inc(iPlus);
  end; 

  //大きさを指定
  MyRect.Top:=0;
  MyRect.Left:=0;
  MyRect.Bottom:= Trunc((Printer.PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
  MyRect.Right:= Printer.PageWidth;
  //ファイルを描画
  StretchDrawBitmap(Printer.Canvas, MyRect, Image1.Picture.Bitmap);
  Application.ProcessMessages;

end;  //intLoopの終わり


実は、ここでひと悶着あって・・・

5.rect:TRectとしてはいけません!

実際には、最初に1ページ目だけを表示できる「印刷プレビュー」のプログラムを書き、それを元にして「印刷」プログラムを書いたのだが、いつもの通り、というか、お決まりの「解決策がまったくわからずにトホーに暮れる」・・・ 後から考えれば(理由がわかってみれば)実に「なぁーんだ。そんなコトか」みたいな、でも、それがわかるまでは七転八倒の苦しみとなるイベントに今回も遭遇。

毎回、これが楽しみでプログラムを書いている、そんな気がしないでもないが。

今回のそれは・・・ナニかというと、

「印刷プレビュー」の手続き内では「何の問題もなかった」次のコードだが、

procedure TForm1.Button2Click(Sender: TObject);
begin

  ・・・じんせい、イロイロ・・・

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169)); 

  ・・・タコは、イボイボ・・・

end;

これを「印刷」手続き内に複写して、「印刷プレビュー」手続きにはないPrinterのCanvasへの描画コードを追加するなど、あちこちいじっていたら、いつの間にか・・・

エラーの!マークが付いてる(問題が起きた状況を再現)


構造ペインには・・・


『はぁ?』

だって行末にちゃんとセミコロンあるし・・・みたいな感じ。
・・・てか、なんで、こっちの手続きだけ、エラーになるの???

この時点で、早朝2時頃から連続15時間くらいPCと向かい合っていたため、精神的にはもうフラフラの状態。もちろん、エラーの原因は、まったくわからない。

まったく同じプログラムコードが、あっちの手続きではOK! こっちの手続きではダメな理由は、いったい何なんだろう???

この後も、しばらく、がんばって考えたんだけど、原因はさっぱりわからず、


Delphiを再起動したら直るかなー?

もちろん、直るわけもなく・・・。


もしかして、PCを再起動したら直るかなー??

もちろん、再起動してもエラーは消えない。


万策尽きた感じで、とりあえず、いけない水に手を伸ばし・・・ 心は折れたまま、遥かなる夢の国へ。

翌朝、ってか、午前0時前に目覚めたから、日付はまだ今日だけど・・・
とりあえず、すっきりした頭で問題に再挑戦。ようやくエラーの原因が判明。

ほんとに偶然発見したのだけれど、エラーにならない「印刷プレビュー」の手続きでは・・・

このRectはSystem.TypesのRect関数・・・


これに対し、エラーになる「印刷」の手続きでは・・・

このrectはvar宣言したTRect型の変数・・・

あー!!
わかったー☆☆☆ みたいな

1ページ目だけ表示可能な最初に書いた「印刷プレビュー」の手続きをそっくり「印刷」手続きに複写して、「印刷プレビュー」の手続きには「存在しなかった」プリンタのCanvasへの描画コードを追加したのだが・・・ その時、var宣言部でTRect型の変数rectを宣言していたのだ。

これが追加したPrinterのCanvasへ描画するプログラムの主要部分。

procedure TForm1.btnPrintASheetClick(Sender: TObject);
var
  i, j: Integer;
  strMsg: string;
  PrintALL: Boolean;
  intLoopNum: Integer;
  rect:TRect;
  StrCaption:String;
  StrPrompt:String;
  StrValue1, StrValue2:String;
  Chr : array [0..255]  of  char;

  // ビットマップ用印刷ルーチン
  procedure StretchDrawBitmap(Canvas:TCanvas;  // 描画先キャンバス
                              r : TRect;       // 描画先範囲
                              Bitmap:TBitmap); // ビットマップ
  ・・・省略・・・

begin
  if PrinterSetupDialog1.Execute then
  begin

    //背景を塗りつぶす
    Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    Image1.Picture.Bitmap.Canvas.FillRect(rect(0, 0, 827, 1169));  //エラーになる部分

    //Info
    strMsg:='全員分印刷しますか?'+#13#10+'(個別印刷は「いいえ」)';
    if Application.MessageBox(PChar(strMsg), PChar('情報'), MB_YESNO or MB_ICONINFORMATION) = mrYes then
    begin
      PrintALL:=True;
    end else begin
      PrintALL:=False;
    end;

    //全部印刷
    if PrintAll then
    begin
      //先頭のデータを表示
      btnFirstClick(Sender);
      for i := 1 to ListBox1.Items.Count do
      begin
        //まず現在のImageを印刷
        with Printer do
        begin
          if i=1 then
          begin
            BeginDoc;
          end else begin
            NewPage;
          end;
          //大きさを指定
          rect.Top:=0;
          rect.Left:= 0;
          rect.Bottom:= Trunc(( PageWidth / Image1.Picture.Width) * Image1.Picture.Height);
          rect.Right:= PageWidth;
          //TImageのBitmapをPrinterのCanvasに描画
          StretchDrawBitmap(Printer.Canvas, rect, Image1.Picture.Bitmap);
          
          if i=ListBox1.Items.Count then
          begin
            EndDoc;
          end;
        end;

        //次を表示
        btnNextClick(Sender);
      end;

      ・・・

だから、結果的に(当然だが)「印刷」手続き内ではコードで意図したSystem.TypesのRect関数は呼ばれずに、「rect」と記述するとそれはvar宣言したrect変数の方を意味(参照)することになって・・・

これはエラーになって当然。Delphiさん、あなたはやっぱり正しかった。

ちなみに、Delphiは大文字・小文字を区別しないから、R でも r でも問題は起きない。問題の根源であるvar宣言部のrect変数の名前を変え、次のようにコードを書き直せば・・・

☆エラーは消えました☆

System.TypesのRect関数と名前が衝突しないように、変数名をMyRectに変える
もしくはSystem.Types.pasのRect関数を明示的に指定する


(試してみたい方は、次のコードをコピペしてください。)

procedure TForm1.Button4Click(Sender: TObject);
var
  MyRect:TRect;
begin

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;

  //解決方法その1
  MyRect:=rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //解決方法その2
  Image1.Picture.Bitmap.Canvas.FillRect(System.Types.Rect(0, 0, 827, 1169));

end;

【まとめ】

関数名として使われているような語句をそのまんま変数名として利用するのはNo Good! であります。

6.印刷プレビューのコード

そういう訳で、「印刷」手続きのコードが完成し、問題も解決したので、この完成したコードを「印刷プレビュー」に流用することにした。

基本的に、「印刷」手続きのコードからBeginDocとNewPage、それからEndDocを消し、PrinterのCanvasに描画してる部分をコメントアウトして、1ページ目以降を表示する方法を追加すればOKのはずだ・・・。そう考えて書いたのが次のコード。

procedure TForm1.Button2Click(Sender: TObject);
var
  //用紙サイズ、縦置き・横置きの設定を知る(Charだと推奨されない警告が表示される->Stringに変更)
  //Device, Driver, Port: array[0..255] of Char;
  Device, Driver, Port: string;
  DeviceMode: THandle;
  DevMode: PDeviceMode;

  //StringGrid->CSVファイル名とそこまでのPathを入れる
  csvFN:string;

  StringList: TStringList;
  i, j, k, MaxWidth: Integer;
  Fields: TStringList;
  FieldWidths: array of Integer;
  ColMargin: Integer;
  MarginX, MarginY: Integer;
  intLoop: Integer;
  FontHeight: Integer;
  eNum: Integer;
  iPlus: Integer;
  myFieldElement: string;
  LowNum: Integer;
  HighNum: Integer;
  MyRect:TRect;
  //平均値・最高値・最低値
  //DSum: Double;
  //DAvg: Double;
  //MinValue, MaxValue: Double;

  //StringGrid -> CSV File
  procedure SaveStringGridToCSV(StringGrid: TStringGrid; const FileName: string);
  var
    CSVFile: TextFile;
    Row, Col: Integer;
    Line: string;
  begin
    AssignFile(CSVFile, FileName);
    Rewrite(CSVFile);
    try
      for Row := 0 to StringGrid.RowCount - 1 do
      begin
        Line := '';
        for Col := 0 to StringGrid.ColCount - 1 do
        begin
          Line := Line + StringGrid.Cells[Col, Row];
          if Col < StringGrid.ColCount - 1 then
            Line := Line + ',';
        end;
        WriteLn(CSVFile, Line);
      end;
    finally
      CloseFile(CSVFile);
    end;
  end;

  procedure GetMinMaxValues(StringGrid: TStringGrid; ColIndex: Integer; out MinValue, MaxValue: Double);
  var
    Row: Integer;
    Value: Double;
  begin
    if StringGrid.RowCount = 0 then
      raise Exception.Create('StringGridにデータがありません。');

    MinValue := MaxDouble;
    MaxValue := -MaxDouble;

    for Row := 1 to StringGrid.RowCount - 1 do
    begin
      if TryStrToFloat(StringGrid.Cells[ColIndex, Row], Value) then
      begin
        if Value < MinValue then
          MinValue := Value;
        if Value > MaxValue then
          MaxValue := Value;
      end;
    end;

    if MinValue = MaxDouble then
      raise Exception.Create('指定された列に数値データがありません。');
  end;

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);
    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, 
      DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);
    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

begin

  //複数回クリックを防止する
  Button2.Enabled:=False;

  //初期化
  Image1.Picture:=nil;

  //印刷設定(用紙・向き)後に印刷

  //プリンタの設定を取得
  Printer.GetPrinter(Device, Driver, Port, DeviceMode);
  DevMode := GlobalLock(DeviceMode);
  try
    //用紙サイズをA4に設定
    DevMode^.dmPaperSize := DMPAPER_A4;
    //用紙方向を縦に設定
    DevMode^.dmOrientation := DMORIENT_PORTRAIT;
    //設定をプリンタに反映
    Printer.SetPrinter(Device, Driver, Port, DeviceMode);
  finally
    GlobalUnlock(DeviceMode);
  end;

  //TImageの初期設定
  Image1.Width := 827;
  Image1.Height := 1169;
  Image1.Picture.Bitmap.Width := 827;
  Image1.Picture.Bitmap.Height := 1169;

  //背景を塗りつぶす
  Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
  MyRect:=Rect(0, 0, 827, 1169);
  Image1.Picture.Bitmap.Canvas.FillRect(MyRect);

  //使用するフォント(必ず等幅フォントを指定する)
  //数値の右揃え用に追加(20240820)
  Image1.Picture.Bitmap.Canvas.Font.Name:='Consolas';

  //フォントサイズ
  Image1.Picture.Bitmap.Canvas.Font.Size:=11;
  //フォントサイズ <- 要らなかった!
  //Image2.Picture.Bitmap.Canvas.Font.Size:=11;

  //平均値を計算  intDenominはグローバル変数として宣言
  {
  DSum:=0;
  for i := 1 to StringGrid1.RowCount do
  begin
    if StringGrid1.Cells[5,i] <> '' then
    begin
      DSum:= DSum + StrToInt(StringGrid1.Cells[5,i]);
    end;
  end;
  DAvg:= SimpleRoundTo(DSum / intDenomin, -2);

  //最高値及び最低値を計算
  GetMinMaxValues(StringGrid1, 5, MinValue, MaxValue);

  //タイトルを描画
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
  Image1.Picture.Bitmap.Canvas.TextOut(
    StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30, LabelKoza.Caption);

  //フォント色を変更
  Image1.Picture.Bitmap.Canvas.Font.Color := clBlack;
  }

  //Grid -> CSV
    //実際のプログラムでは、sNameフォルダ内のCSVファイルを読み込み、
    //さらに幾つかフィールドを追加して新しいデータを入力している。
    //印刷では、この新しく入力されたデータを含めて印刷している

    //実際のプログラムでは、LabelSaveFolderName.Captionは別手続きで取得・表示済み
    LabelSaveFolderName.Caption:='SampleData';

    //保存するフォルダへのPath
    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\';

    //フォルダの存在を確認、なければ作成
    if not System.SysUtils.DirectoryExists(ExtractFileDir(csvFN)) then
    begin
      //フォルダ階層を作成
      System.SysUtils.ForceDirectories(ExtractFileDir(csvFN));
    end;

    csvFN:=IncludeTrailingPathDelimiter(ExtractFilePath(Application.ExeName))+
      'ProcData\'+LabelSaveFolderName.Caption+'\'+LabelSaveFolderName.Caption+'.csv';
    SaveStringGridToCSV(StringGrid1, csvFN);

  StringList:=TStringList.Create;
  Fields:=TStringList.Create;

  try

    //Create
    StringList.LoadFromFile(csvFN);
    //Create
    SetLength(FieldWidths, 0);

    //各フィールドの最大幅を計算
    for i := 0 to StringList.Count - 1 do
    begin
      Fields.CommaText := StringList[i];
      if Length(FieldWidths) < Fields.Count then
        SetLength(FieldWidths, Fields.Count);

      for j := 0 to Fields.Count - 1 do
      begin
        //MaxWidth := Printer.Canvas.TextWidth(Fields[j]);
        MaxWidth := Image1.Picture.Bitmap.Canvas.TextWidth(Fields[j]);
        if FieldWidths[j] < MaxWidth then
          FieldWidths[j] := MaxWidth;
      end;
    end;

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;    

    //背景を塗りつぶす
    Image2.Picture.Bitmap.Canvas.Brush.Color := clWhite;
    case eNum of
      0:MyRect:=Rect(0, 0, 827, 1169);
    else
      MyRect:=Rect(0, 0, 827, 1169 * (eNum + 1));
    end;
    Image2.Picture.Bitmap.Canvas.FillRect(MyRect);

    //51,101,151,201,251,301,・・・,XX1番目にフィールド名を挿入しておく

    //0番目の要素をコピー
    myFieldElement:=StringList[0];
    //要素を挿入(追加)
    if eNum<>0 then
    begin
      for i := 1 to eNum do
      begin
        StringList.Insert((50*i)+1, myFieldElement);
      end;
    end;

    //ここから印刷Loop
    try

      for intLoop := 0 to eNum do
      begin

        //初期化(白紙にする)
        Image1.Picture.Bitmap.Canvas.Brush.Color := clWhite;
        MyRect:=Rect(0, 0, 827, 1169);
        Image1.Picture.Bitmap.Canvas.FillRect(MyRect);
        {
        //印刷プレビューだから不要
        if intLoop=0 then
        begin
          Printer.BeginDoc;
        end else begin
          Printer.NewPage;
        end;
        }

        //タイトルを描画
        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelSaveFolderName.Caption);

        //タイトルを描画(計算が必要な場合の例)
        {
        Image1.Picture.Bitmap.Canvas.Font.Color := clBlue;
        Image1.Picture.Bitmap.Canvas.TextOut(
          StrToInt(EditMarginX.Text), StrToInt(EditMarginY.Text)-30,
          LabelKoza.Caption + ' 【平均値:'+FloatToStr(DAvg)+
          '、最高値:'+ FloatToStr(MaxValue)+
          '、最低値:'+ FloatToStr(MinValue)+'】');
        }

        Image1.Picture.Bitmap.Canvas.Font.Color:=clBlack;

        //水平方向の各フィールドの印字開始位置決定用変数を初期化
        k:=0;

        //水平方向の印字開始位置
        MarginX:=StrToInt(EditMarginX.Text);
        //垂直方向の印字開始位置
        MarginY:=StrToInt(EditMarginY.Text);
        //列(フィールド)と列の(余白的な)間隔
        ColMargin:=StrToInt(EditColMargin.Text);

        //ページが変わったら初期化する
        iPlus:=0;

        case intLoop of
          0:begin
            LowNum:=0;
            if StringList.Count > 50 then
            begin
              HighNum:=50;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          {
          1:begin
            LowNum:=51;
            if StringList.Count > 100 then
            begin
              HighNum:=100;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          2:begin
            LowNum:=101;
            if StringList.Count > 150 then
            begin
              HighNum:=150;
            end else begin
              HighNum:=StringList.Count-1;
            end;
          end;
          }
        else
          //一般化
          LowNum:=(intLoop*50)+1;
          if StringList.Count > (intLoop*50)+50 then
          begin
            HighNum:=(intLoop*50)+50;
          end else begin
            HighNum:=StringList.Count-1;
          end;
        end;

        for i := LowNum to HighNum do
        begin
          Fields.CommaText := StringList[i];
          for j := 0 to Fields.Count - 1 do
          begin
            //処理できる列数を無制限にする
            case j of
              0:k:=0;
            else
              k:=k+FieldWidths[j-1]+ColMargin;
            end;
            //フィールド名に「備考」を追加する
            if i=0 then
            begin
              if j=Fields.Count-1 then
              begin
                Fields[j]:=Fields[j]+' 備考';
              end;
            end;

            //データを出力(数値の右揃え:なし)
            //Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);

            //データを出力(数値の右揃え:あり)
            //数値の右揃え用に追加(20240820)
            if TryStrToInt(Fields[j], intValue) then
            begin
              //数値である -> 右揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),
                Format('%3d', [strToInt(Fields[j])]));
            end else begin
              //数値でない -> 左揃えで出力する
              Image1.Picture.Bitmap.Canvas.TextOut(MarginX+k,MarginY+(iPlus*20),Fields[j]);
            end;

            //罫線を描画
            if cbLine.Checked then
            begin
              Image1.Picture.Bitmap.Canvas.Pen.Color:= clBlack;
              FontHeight:= -1 * Image1.Picture.Bitmap.Canvas.Font.Height;
              Image1.Picture.Bitmap.Canvas.MoveTo(MarginX+k, MarginY+(iPlus*20)+FontHeight+4);
              Image1.Picture.Bitmap.Canvas.LineTo(Image1.Picture.Bitmap.Width-50, MarginY+(iPlus*20)+FontHeight+4);
            end;
          end;

          inc(iPlus);

        end;

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;

      end;

    finally
      //Printer.EndDoc;
    end;

  finally
    StringList.Free;
    Fields.Free;
    //複数回クリックを防止する
    Button2.Enabled:=True;
  end;

  //ファイルの完全削除
  DeleteFile(PChar(csvFN));

  //Imageの高さをScrollBoxのスクロール範囲に反映
  ScrollBox1.VertScrollBar.Range := Image2.Picture.Bitmap.Height;

  //TImageの表示位置を指定
  ScrollBox1.VertScrollBar.Position:=0;
  ScrollBox1.HorzScrollBar.Position:=0;
  Image1.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image1.Left:=ScrollBox1.HorzScrollBar.Position+14;
  Image2.Top:=ScrollBox1.VertScrollBar.Position+14;
  Image2.Left:=ScrollBox1.HorzScrollBar.Position+14;

  //TImageの表示
  Image1.Visible:=False;
  Image2.Visible:=True;

end;


【実行結果】

実行結果は、次の通り。用意したデータ件数は320件。

1ページ目


スクロールして下へ。表示されているのは、最終ページ。
※ マウスのホイールを廻してスクロールさせるには別途コードの記述が必要(後述)。

最終ページ


マウスのホイールを廻して、TImageをスクロールさせるには、FormのOnMouseWheelイベントの手続きを次のように作成する。

procedure TForm1.FormMouseWheel(Sender: TObject; Shift: TShiftState;
  WheelDelta: Integer; MousePos: TPoint; var Handled: Boolean);
var
  LDelta:Integer;
  //追加
  LWinCtrl:TWinControl;
  LCurPos:TPoint;
begin

  {
  //TScrollBox のマウスホイールによるスクロール
  //マウスがTScrollBoxの外にあってもスクロールする・・・ならこちら☆
  LDelta:=WheelDelta div 5;
  if ssCtrl in Shift then
  begin
    ScrollBox1.HorzScrollBar.Position:=ScrollBox1.HorzScrollBar.Position-LDelta;
  end else begin
    ScrollBox1.VertScrollBar.Position:=ScrollBox1.VertScrollBar.Position-LDelta;
  end;
  Handled:=True;
  }

  //マウスカーソルが TScrollBox の領域内にある時だけスクロールを可能にする
  LCurPos := ScrollBox1.Parent.ScreenToClient(MousePos);
  if PtInRect(ScrollBox1.BoundsRect, LCurPos) then
  begin
    LDelta := WheelDelta div 3;
    if ssCtrl in Shift then
    begin
      ScrollBox1.HorzScrollBar.Position := ScrollBox1.HorzScrollBar.Position - LDelta;
    end else begin
      ScrollBox1.VertScrollBar.Position := ScrollBox1.VertScrollBar.Position - LDelta;
      //Memoも連動してスクロールさせる
      {
      if LDelta > 0 then
      begin
        Memo2.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        Memo2.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
      }
    end;
  end else begin
    //マウス直下のコントロールを取得
    LWinCtrl := FindVCLWindow(MousePos);
    //TStringGridの場合
    if LWinCtrl is TStringGrid then
    begin
      if WheelDelta > 0 then
      begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEUP, 0);
      end else begin
        LWinCtrl.Perform(WM_VSCROLL, SB_LINEDOWN, 0);
      end;
    end;
  end;

  //この1行を忘れないこと!
  Handled:=True;

end;


【印刷プレビューコードの工夫】

1.ページ毎に作成されるImage1のBitmapを、Image2のCanvasの指定位置に複写する。
2.最初にページ数を調べ、Image2の高さをページ数にあわせて高くしておく。
3.1の複写手続きの最後に、ページ区切り線を描画するコードを追加(20240820)。

1.に関して、Image1のBitmapをImage2のCanvasに複写する手続き

  //Image1のBitmapをImage2の指定位置へ複写する
  procedure CopyBitmapToImage(Image1, Image2: TImage; DestX, DestY: Integer);
  var
    SrcRect, DestRect: TRect;
  begin
    // ソースの矩形を設定
    SrcRect := Rect(0, 0, Image1.Picture.Bitmap.Width, Image1.Picture.Bitmap.Height);

    // 目的地の矩形を設定
    DestRect := Rect(DestX, DestY, DestX + Image1.Picture.Bitmap.Width, DestY + Image1.Picture.Bitmap.Height);

    // Image2のCanvasにImage1のBitmapを複写
    Image2.Picture.Bitmap.Canvas.CopyRect(DestRect, Image1.Picture.Bitmap.Canvas, SrcRect);
  end;

    //追加(20240820)
    //ページ区切り線を表示するコードを追加
    //ペンの色を青に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clGray;
    //ページ区切り線の太さ
    Image2.Picture.Bitmap.Canvas.Pen.Width:=3;
    //ペンのスタイルを点線に設定
    //Image1.Canvas.Pen.Style := psDot;
    //ペンのスタイルを直線に設定
    Image1.Canvas.Pen.Style := psSolid;
    //線を引く
    Image2.Canvas.MoveTo(0, DestY + Image1.Picture.Bitmap.Height); // 線の開始位置
    Image2.Canvas.LineTo(Image2.Picture.Bitmap.Width, 
      DestY + Image1.Picture.Bitmap.Height); // 線の終了位置
    //ページ区切り線の太さを元に戻す
    Image2.Picture.Bitmap.Canvas.Pen.Width:=1;
    //ペンの色を黒に設定
    Image2.Picture.Bitmap.Canvas.Pen.Color := clBlack;
    //ペンのスタイルを直線に戻す
    //Image1.Canvas.Pen.Style := psSolid;
  end;

ページ区切り線については、いろいろ試行した結果、やや太い灰色の直線が最も適している(ほどよく自己主張するが、データほどではない)と感じたので、そのように設定。上のコードにはその痕跡を残している。


1.に関して、複写する手続きを呼び出すコード。

        //Image1のBitmapをImage2の(XX, YY)の位置に複写
        case intLoop of
          0:CopyBitmapToImage(Image1, Image2, 0, 0);
        else
          CopyBitmapToImage(Image1, Image2, 0, 1169 * intLoop);
        end;


2.に関して、ページ数に応じてImage2の高さを高くするコード。

    eNum:=StringList.Count div 50;

    //PreView用TImageの初期設定
    Image2.Width := 827;
    case eNum of
      0:Image2.Height := 1169;
    else
      Image2.Height := 1169 * (eNum + 1);
    end;
    Image2.Picture.Bitmap.Width := 827;
    case eNum of
      0:Image2.Picture.Bitmap.Height := 1169;
    else
      Image2.Picture.Bitmap.Height := 1169 * (eNum + 1);
    end;

7.まとめ

(1)StringGridのデータを用紙と向きを指定して罫線付きで印刷するコードを掲載
(2)(1)のコードを流用して、印刷プレビューを表示するコードを掲載
(3)変数名を付ける時は既存の関数名等との衝突に十分に注意する。

8.お願いとお断り

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

塗りつぶさないマークシート

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートの例。

複数マーク可能なマークシート(選択肢は0~99 に対応)を作成したら、マークする時間そのものを短縮する必要性を痛感。今回は「よりはやく」・「よりカンタン」にマーク可能なシート作りに挑戦。

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

上のリンク先で、ここで紹介したマークシートを含む、デジタル採点プログラム一式をダウンロードできます。

【もくじ】

1.もっとはやくマークできないか?
2.凡例として線でマークして違和感のないカタチは?
3.「線」マークの読み取りテスト
4.まとめ
5.お願いとお断り

1.もっとはやくマークできないか?

設問数が100ある試験で、複数マークを可とした場合、受験者は制限時間内に最大で200近いマークを塗りつぶさなくてはならない。制限時間が50分の試験で、190個マークするとして、1個2秒でマークした場合、マークするのに必要な時間は380秒、すなわち6分20秒となる。1個3秒でマークした場合の必要時間は、なんと9分30秒。制限時間の1/5がマークするためだけに使われてしまう。

採点者がラクをするのに、無理やり付き合わされる受験者の不満が爆発するのが見えるような気がして、小心者の筆者は「マークするために必要な時間を短縮する」にはどうしたらいいか、必死で考えた。

これまでに使用してきたマークシートのマークは、すべて選択肢の番号を縦長だ円で囲んだもの。

これまでに使用してきたマークシート


実際には、テストしてみると、次のようにマークしても読み取りパラメータの設定次第で十分読み取り可能なのだが・・・

こんなマークでも、実際には読み取りが可能。


しかし、受験者の心理として、選択肢が縦長だ円で囲まれていれば、だ円内を塗りつぶしたくなるもの。塗りつぶさず、「線」でマークしてもらうには、「線」でマークしたくなる形状にマークシートを改良しなければならない。

最初に考えたのは、「選択肢の番号を縦長四角形で囲む」という方法。

たとえば、こんな感じ


ただ、これだと、凡例で次のようにマーク方法を示してあっても・・・

どちらかと言えば、これはむしろ「悪いマーク」の例


真面目な受験者であれば、あるほど・・・

枠内を塗りつぶさなくてはいけないと考えるのが自然。


かといって、次のようにすると、デザインとして美しくない気がするし、四角形の幅が狭まったたけで「塗りつぶし」たくなる気持ちは同じ。

塗りつぶし面積が減少しただけ?


そんな、こんな理由から縦長四角形で選択肢の番号を囲むというアイデアは見送ることに決定。

2.凡例として線でマークして違和感のないカタチは?

では、凡例として線でマークしてあっても受験者に違和感を与えないマークとは、どんなマークか?

思いついたのは「囲みのない」マーク。これなら・・・

凡例として「線」が使えるし、塗りつぶしたくても塗りつぶす枠がない!


ただ、これだとマークシートっぽくない気が・・・。

マークシートらしくするために一工夫した結果・・・

[ ] 記号で数字の上下を囲んでみた


これならマークの凡例として、「線」が使えるのではあるまいか?


で、出来上がったのが、このマークシート。

複数マーク可能な「線」でマークするシート(設問数は100設問まで)


このマークシートは、次のリンク先からダウンロードすることができます。

3.「線」マークの読み取りテスト

例え、「線」であっても、これまでの経験からまず間違いなくマークの読み取りには成功すると思ったが、「やっぱりできませんでした!」では使ってくださる方に申し訳がたたないので、念のためテストを実行。

読み取り結果を確認しやすいように、設問番号にマークした次のようなマークシートを作成。やっぱり「線」で引く方が、「塗りつぶし」より遥かにラクであることを実感。これなら1設問に対し2か所マークしても「塗りつぶす」より、全然はやい!!

筆記用具は、シャープペンシルを使用。
芯は、HBの0.5mm。
用紙はホームセンターなどで普通に販売されている白色度の高いコピー用紙を使用。
(普段マークシートの印刷に使用している再生紙より、白色度が高いもの)


MS_Reader.exe を起動し、パラメータ設定はデフォルトのまま、読み取りテストを実行。

スキャナーの解像度は200dpiに設定。
カラー画像としてスキャン。
テスト1回目で、全てのマークの読み取りに成功!


間違いなく読めると思ってはいたけれど、ちょっと(あれっ?)って思ったのは、デフォルトパラメータの設定のまま、テスト第1回目で、全てのマークを正しく読み取ることに成功したこと。

実は、今回、実験で使用した用紙とは異なる、白色度が70%程度の再生コピー用紙を用いて予備的に実験した際は、閾値のパラメータをデフォルト設定より1~3程度大きく設定しないと読み取れないマークがあったのだ。今回、実験したものと「線」そのものの濃さや太さが厳密には異なるから正確なことは言えないが、白色度が高い用紙の方が二値化の際に有利なのだろうか?

・・・ということで、今回の実験では何も問題が起きなかったので、その前に行った白色度が70%程度の再生コピー用紙を使って行った予備実験で発見できた問題と対応方法を紹介。

(これは当Blogの過去記事「100選択肢対応マークシートを使用した試験の実施方法」に書いたものの再録です)

【重要】二値化閾値の修正方法(20240707追加)

マークが「うすい」場合、これを正しく読み取ることができず、「空欄」と判定して「999」と表示される場合があります。同じ理由で、複数マークされた解答欄の「1の位」が読めなかった場合も、読み取り判定は「マークの状態に問題あり」となり、「999」と表示されます。

これらの場合は、この後、実行する「読み取り結果のチェック」時に、該当箇所の解答欄が赤枠で囲まれて表示されますので、マークの状態をヒトの眼で確認し、読み取り結果を修正できます。

最も困るのが、複数マークされた解答欄の「10の位」のマークは薄くて読めなかったが、「1の位」のマークの読み取りには成功している場合です。この場合は正しく読み取れた「1の位」のマークが読み取り結果として表示されてしまいます。大変申し訳ないのですが、MS_Reader.exe のチェックプログラムは、この誤読を見つけることができません!!(これは原理的な問題なので、チェックする方法がありません)

この事故を防ぐには、事前に読み取りテストを十分に行って、読み取りパラメータを調整するしか方法がありません。具体的には、最もマークの濃度が薄い受験者のマークシートが正しく読めるようになるまで、閾値の値を1ずつ大きくして読み取りテストを実行します。筆者の行ったテストでは、デフォルト設定の閾値(180)では正しく読めなかったマークも、閾値を大きくすれば読めるようになりました。

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートを用いて行った読み取りテストの例

「64」と読むべき箇所の「10の位」を「空欄」と判定、「1の位」は正しく読めた場合、プログラムは読み取り結果を「4」と表示してしまう。


閾値を「1」大きくすると・・・

正しく読めるようになりました。


ごく薄いマークも正しく読めるようになるまで、この操作をくり返します。筆者の行ったテストでは、閾値を1ずつ大きくすることで、指示通り普通の濃さ(マーク箇所の数字が読めなくなる濃さ)でマークされたシートであれば、最終的にすべてのマークを正しく読めるようになりました(上記のマークシートを使用して行った実験では、最終的にすべてのマークを正しく読めた段階で、二値化の閾値の値は「184」でした。なお、このマークシートは後日公開する予定です)。

ただ、あまりにもマークの濃さが薄い場合は「原理的に読めません」ので、「対応不可」としてヒトの眼で読むしかないと思います・・・。

そのような事態にならないよう、予め受験者に対し「薄いマークは読み取れない」旨の注意を徹底しておく必要があります。マークシートを用いた試験では、これが最も重要なことかもしれません。

4.まとめ

(1)複数マーク可能なマークシートを使う場合は、「線」でマークできるようにする。
(2)マークシートを印刷する用紙は、白色度の高いものがよい?カモ
(3)マークシートは「濃く」マークするよう、予め注意を徹底する。
(4)筆記用具は、硬さと濃さが2B、太さ0.9mmのシャープペンシルがよいと思われます。

追記_20240709

【注意】複数マーク可能なマークシートでは、音声読み上げ機能は正しく動作しません!

5.お願いとお断り

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

100選択肢対応マークシートを使用した試験の実施方法

自作マークシートリーダーのプログラムを書き替え、大語群(使用できる選択肢の番号は 0~99)の使用に対応したマークシートを Excel で作成した。

今回は、このマークシートを用いた試験を実施する方法です。

【もくじ】

1.事前の準備と受験者への注意事項
2.スキャナーでマークシートをスキャンしてJpeg画像に変換
3.指定のフォルダ内にフォルダを作成してスキャンした画像をコピー
4.採点専用画像に変換
5.テンプレートとして登録
6.マーク読み取りを実行
7.マーク読み取り結果のチェック
8.CSV形式で出力
9.採点結果通知シートの作成方法
10.お願いとお断り

追記(20240929)

当Blogで紹介してきた自作のデジタル採点プログラムを一つにまとめました。次のリンク先にその紹介とダウンロードリンクがあります。マークシートも、ここに紹介した形式の他、様々なタイプのものを同梱しています。

追記(20250702)

100選択肢用のマークシートを使って試験を行ったユーザーの方から、『マークの塗りつぶし面積が大きいと誤判定が出やすい』とのご指摘をいただきました。確認すると、受験者は「濃く・丁寧に」マークを塗りつぶしたことがマークシートから伝わってくるのですが、100選択肢用のA4横サイズのマークシートは・・・やはり、と言うか、どうしてもマークそのものが小さく、受験者によっては「きちんとマークすること=大きくマークすること」と、感じて(?)、選択肢番号の外枠の楕円「0」部分を上下左右に大きくはみ出して隣のマークの領域範囲まで塗りつぶしてしまい、結果的に、その受験者のマークシートは複数マーク判定だらけになることがわかりました。

対策として、『選択肢番号の外枠の楕円「0」部分をはみ出さないようにマークする』よう注意を徹底することをお願いしたのですが、それだけでは根本的な解決とならないように感じ、マークとマークの間隔が狭いためにこの問題が起きていることは明白ですから、1設問について100選択肢に対応を維持しつつ、1列25行×4列で100設問まで対応という現在のマークシート構成を見直し(マーク間の幅を広げるため列数を減らし)、1列33行×3列で99設問まで対応可能というマークシートを作成しました。また、50分という試験時間を考えると80設問あれば十分というご意見も頂戴しましたので、1列30行×3列で90設問まで対応可能なマークシートや、1列25行×3列で75設問まで対応可能なマークシートを作成し、これらのマークシートを1つの Excel Book にまとめました。以下のリンクからダウンロードできます。

1.事前の準備と受験者への注意事項

(1)マークシートの印刷

プログラムに添付した R25C04D19.xlsx をダブルクリックして開き、必要な枚数をインクジェットプリンタで印刷します。印刷設定はA4・横に設定済みです。用紙はホームセンター等で購入できるコピー用紙を使用してください(読み取りパラメータの設定とマークの読み取りテストは再生コピー用紙を使用して行っています)。マークや罫線枠が設定よりも濃く印刷される輪転機での印刷はお止めください。

100選択肢(0~99)に対応した、複数選択可能なマークシート 「R25C04D19.xlsx」


(2)受験者への注意事項

試験を実施する前にHB以上の濃さの鉛筆とプラスチック消しゴムを用意するよう、受験者全員に連絡してください。可能であれば、シャープペンシルの使用は禁止した方がよいと思います。

※追記(20240924)
どうしてもシャープペンシルを使用したい場合は、硬さ・濃さは「2B」で、芯の太さは「0.9mm」以上のものであれば使用可とするような「条件付き許可」とした方がよろしいかと思います。

問題冊子の表紙には、次の注意事項を印刷してください。


上記注意事項のサンプルをダウンロードできます。

2.スキャナーでマークシートをスキャンしてJpeg画像に変換

(1)スキャンの準備

解答用紙(マークシート)をスキャナーでスキャンする前に、解答用紙が裏返しだったり、逆さまになっていないかどうか等の確認に加え、次のことを必ず実行してください。

・解答用紙に付着している消しゴムの屑をしっかり落とす。
・受験番号(出席番号)が昇順になるよう、解答用紙の並び順を2回以上確認する。
・欠席者がいる場合は、未使用の解答用紙を該当箇所に挿入する。

(2)スキャナーの設定

解答用紙(マークシート)をスキャンする際の読み取り解像度は 200dpi で十分です(解像度を大きく設定しても、MS_Reader.exe での利用に関する限り、メリットは何一つありません)。また、画質は「カラー」を指定し、出力先はPDFファイルではなく、Jpeg画像を指定してください。

3.指定のフォルダ内にフォルダを作成してスキャンした画像をコピー

スキャンしたJpeg画像は、MS_Reader.exeと同じ階層にあるScanDataフォルダ内に適切な名称のフォルダを作成し、そこに保存してください。

【利用できるフォルダ構造】

〇:ScanData¥1年A組

【利用できないフォルダ構造】

×:ScanData¥1年¥A組

ScanData フォルダ内に新規にフォルダを作成し、さらにそのフォルダ内に新規にフォルダを作成して、そこにスキャンした Jpeg 画像を保存するような使い方は出来ません。ご注意ください。

4.採点専用画像に変換

ScanData フォルダ内に新規に作成したフォルダにスキャンしたJpeg画像が用意できたら、次にこの画像をマークシートリーダーで読むための専用画像に変換します。その理由はいくつかありますが、主なものは次の三つです。

(1)読み取り原本は、オリジナル状態のまま残しておく。
(2)マーク読み取り後に人の眼でチェックする際、作業しやすい大きさに整える。
(3)Loop 処理するため、画像の名称を統一し、1から始まる連番の番号を付ける。

具体的な方法は次の通りです。

(1)ダウンロードした Zip ファイルを展開したフォルダ内にある MS_Reader を起動します。

上のアイコンをダブルクリックして起動します。


(2)画面左上のメニューの「1 画像変換」をクリックします。


(3)表示されたサブメニューの「専用画像を作成」をクリックします。


(4)次のフォームが表示されます。画面右上の「選択」ボタンをクリックします。


(5)ScanDataフォルダの内容が表示されます。マークの読み取り処理を行いたいJpeg画像を保存したフォルダをクリックして選択します。選択したフォルダ名が下の「Folder」部分に表示されていることを確認して「OK」をクリックしてください。

【重要】選択するのは「フォルダ」で、「ファイル」ではありません!

Folder部分に、選択したフォルダ名が表示されます。


(6)画面は次のようになります。画面の中央左に表示されたサムネイル画像を参考にして、画像の回転の有無及び回転方向を選択し、続けて、リサイズの有無と縮小率を指定します。読み取り解像度が200 dpi ・A4横形式で、マークシートの列が4列ある場合は、「80%に縮小」してください。


(7)変換処理を行った画像データの出力先(書き出し先)フォルダを指定します。「参照」ボタンをクリックしてください。再びフォルダの選択ダイアログが表示されます。


(8)プログラムは、(5)で指定されたScanDataフォルダ内のフォルダと同じ名前のフォルダをProcDataフォルダ内に自動的に作成します。フォルダをクリックして選択し(下のFolder部分に選択したフォルダ名が表示されたことを確認)、OKをクリックしてください。

【参考】ProcData は、Processed(加工済み)の意味です。短くしすぎカモですが・・・


(9)ProcDataに続くPathを確認し、「変換実行」ボタンをクリックします。

〇:ProcData¥(自動的に作成されたスキャンした画像を保存したフォルダと同名のフォルダ)

×:ProcData¥AAA¥BBB¥CCC


(10)次のメッセージが表示されます。MS_Readerは、筆者が別に作成した手書き答案の採点プログラムと併用して使用することもできますが、今回の処理ではそのような形では運用しませんので「いいえ」ボタンをクリックしてください。


(11)専用画像の作成が完了すると、次のメッセージが表示されます。OKをクリックしてください。


(12)「終了」ボタンをクリックして、変換作業を終了します(画面下・中央の「画面の初期化」ボタンをクリックすれば、続けて他のクラス/講座の画像を同様に処理することも可能・・・なようにプログラミングしたのですが、「画面の初期化」ボタンのクリックでは「初期化されない何か」が残ってしまうバグが発現することがあるようです。意図した通りに変換されない状況を1度、経験しました)。なので、他のクラス/講座の画像を処理する際は、一旦終了してから再度このプロセスを呼び出していただいた方がよろしいかと思います。

ド素人が書いたプログラムであります。内在する不具合につきまして、もし、それが発現するようなことがありましたならば、ただ、ただ、伏してお詫び申し上げます。不具合がありましたら、一旦「終了」をクリックして、再度、画像変換処理を呼び出していただけますよう、お願い申し上げます。

変換後のファイル名はProcDataフォルダ内に作成した変換先フォルダの名称+01から始まる連番となります。

5.テンプレートとして登録

筆者の作成したマークシートリーダーでは、マークの読み取りに際し、まず特徴点(マークシート画像内のトリプルドット:■■■ )をコンピュータの眼である OpenCV を用いて探し出し、三つの ■ のうち最も左の ■ の左上隅を座標原点(0,0)として、ここからの距離情報を利用してマークシートの第1列を画像として切り出し、二値化・色の反転処理を行って、さらにそこから1行ずつ「行の画像」を切り出し、この「行の画像」を選択肢数個に切り分けて白面積を計算、それが大きい場合に「マークあり」と判定しています。

このため最初にマークシートの特徴点の位置と各列の左上隅及び右下隅の位置を座標として登録し、これをそのマークシート形式の定型フォーマット(=テンプレート)として利用できるように名前を付けて登録する処理を行います。

テンプレートの名前の意味は、次の通りです。

例:R25C04D19

1列あたりの行数・全列数・選択肢の形式と選択肢数を「行・列・選択肢」順に並べています。
R は Row (=行)、すなわち1列 25 行より成ること、
C は Column (=列)、すなわち4列あること、
D は Double 型、すなわち複数マーク対応で、1行あたりの選択肢数は 19 個。
(ここが S の場合は Single 型、複数マーク不可)

Word や Excel で作成したマークシートを、同じインクジェットプリンタで印刷して使用しているので、試験を実施する度にテンプレートを登録する必要はないはずなのですが、筆者はなんとなく不安で、毎回新しくテンプレートを登録し直して作業しています・・・

テンプレートの登録方法は、次の通りです。

(1)「2 テンプレート」をクリックして表示されるサブメニューから「テンプレートの新規登録」をクリックして選択します。


(2)別のWindowが開き、次の画面が表示されます。画面右上の「取得」ボタンをクリックします。


(3)ファイル選択ダイアログが表示されます。任意のマークシート画像を1枚選んでクリックして選択してください。下のファイル名欄にクリックした画像の名称が表示されていることを確認して、「開く」ボタンをクリックします。


(4)選択したマークシートが表示されます。画面右上の「マーカー」オプションボタンをクリックしてください。


(5)画像左上の特徴点部分が3倍の大きさで拡大表示されます。特徴点画像の左上位置をポイント(マウスのカーソル:+の中心を合わせる)してクリックしたらそのまま指を離さずに特徴点画像の右下へドラッグしてください。ドラッグ中は黒い太い枠線(=ラバーバンド)が表示されます。

赤枠の中、ラバーバンドの様子がよくわかるように、特徴点画像より少し大きめにドラッグしています。


実際は、次の図のように、ラバーバンドが特徴点画像の外側をぴったり包む(両者の幅と高さが同じになる)ようにドラッグします。


(6)ドラッグ終了時に矩形選択された部分が特徴点画像としてコピーされ、下の図のように表示されます。照合手法は自動的に設定されますので、変更しないでください。続けてコンピュータの眼である OpenCV がマークシート画像内の特徴点を見つけることができるか、どうかのテストを実行します。

「マーカー画像の読み取りテスト」ボタンをクリックしてください。


(7)OpenCVが発見した特徴点画像の位置が赤い矩形で表示されます。特徴点画像を完全に一致していることを確認し、表示されるメッセージを読んでOKをクリックしてください。


(8)選択対象グループの「解答欄」オプションボタンをクリックすると、案内バルーンが表示されます。マークシートの1列あたりの行数と、マークシート全体の列数、1行あたりの選択肢数をコンボボックスの選択肢から選んで順に設定します。


(9)次の図は、複数選択可能なマークシートの設定例で、行数・列数・選択肢数をそれぞれ入力した直後の状態です。複数マークを許可するか、どうかの設定を行います。

【重要】
複数マークを許可する場合は「複数マークによる採点を実施」チェックボックスをクリックして、チェックが入った状態 にしてください!!

GUIのデザインが悪いためか、作った本人でも! この設定の操作を時々忘れます。複数選択可能なマークシートのテンプレート設定を行う場合は、くれぐれも注意してください。


(10)マークシートの1列目から順に、その座標を取得します。1列ラベルの左のオプションボタンをクリックしてください。マウスカーソルの形状が+になります。

次の図に示したように、まずマークシートのマーク欄枠の左上をクリックし、そのまま指を離さずに、第1列めの右下隅へ向かってドラッグしてください。なお、ドラッグ中は、細い点線のラバーバンド矩形が表示されます。

設問番号欄を含めないようにご注意願います。
必要な座標は、マークシート欄の座標です!


列の右下隅までドラッグした状態を示します。


ドラッグを終了する(マウスの左ボタンから指を離す)と、選択範囲が赤の矩形で囲まれます。取得できた第1列目の座標が画面右のラベルに表示されます。

第1列めの範囲を指定し、座標を取得したところ

【重要】
この作業にマーク読み取りの成否がかかっています!
くれぐれも慎重に、正確に、作業してください。

うまく列を選択できなかった場合は、「再範囲選択」ボタンをクリックして作業をやり直すことができます。


(11)以降、4列目まで同じように作業します。4列目の座標を取得できたら、「保存」ボタンをクリックして取得した座標を ini ファイルに保存します。

保存処理が完了するとメッセージが表示されます


(12)最後に「終了」ボタンをクリックして、テンプレート作成の画面を閉じます。

ボタンは画面右下隅にあります。


これでマークシートを読む準備ができました!

6.マーク読み取りを実行

(1)最初に使用するテンプレートを選択します。

「2 テンプレート」をクリックするとサブメニューが表示されます。
「テンプレートの選択」を選んでください。


(2)テンプレートの選択画面が開きます。使用したいテンプレート名をクリックして反転表示させ、「決定」ボタンをクリックしてください。


【参考】テンプレートの削除方法
必要のなくなったテンプレート名をクリックして選択、「テンプレートの削除」チェックボックスをチェック、「実行」ボタンをクリックすれば不要なテンプレートを削除できます。削除したテンプレートを元に戻すことはできません。テンプレートを削除する際は、その要不要に十分ご注意ください。

(3)次のメッセージが表示されます。これはメニューの「3 作業フォルダ」をクリックすると表示されるサブメニューの「作業フォルダの選択」をクリックした際に表示されるメッセージと同じものです。「はい」ボタンをクリックしてください。


(4)フォルダの選択ダイアログが表示されます。ProcData フォルダ内の読み取りたいマークシート画像のあるフォルダをクリックして選択してください。下のFoleder部分に選択したフォルダ名が表示されたことを確認して、「OK」ボタンをクリックしてください。

選択するのは「ファイル」ではなく、「フォルダ」です。


(5)テンプレート名と関連付けて保存されている特徴点画像が見つかった場合は、それを赤の矩形で囲んで表示します。また、特徴点画像からの距離座標を用いてマークシート欄第1列の1行目がどこにあるのかを計算し、その位置をこちらも赤い矩形で囲って表示します。

【参考】PCによっては、ここで Python Engine の初期化に時間がかかることがあります!

次の図のように表示されれば、マークの読み取り準備は完了です。

諸設定が意図した通りに反映され、OpenCVが正しく動作していることを確認したら、
「OK」ボタンをクリックしてください。


(6)マークの読み取りを実行します。案内バルーンが表示されますので、その下にある「読む」ボタンをクリックしてください。


Python4Delphi が使用できる環境(組み込みPython環境を入れた Python39-32 フォルダが MS_Reader.exe と同じフォルダにある場合)ならば、P4D チェックボックスに自動的にチェックが入り、Python 用のOpenCVを用いてプログラムは動作します。


Python4Delphi が利用できない場合、プログラムはDelphi用のOpenCVを利用して動作します。

画面下に表示されている Grid コントロールにすべてのマークシート画像の読み取り結果が表示されたら、マークの読み取りは完了です。通常の動作モードでは、マークの読み取り完了を知らせるメッセージは表示されません。

複数選択可能なマークシートの場合、空欄や3個以上マークされている等、
読み取り結果に何らかの問題がある場合は「999」と表示されます。

【重要】二値化閾値の修正方法(20240707追加)

マークが「うすい」場合、これを正しく読み取ることができず、「空欄」と判定して「999」と表示される場合があります。同じ理由で、複数マークされた解答欄の「1の位」が読めなかった場合も、読み取り判定は「マークの状態に問題あり」となり、「999」と表示されます。

これらの場合は、この後、実行する「読み取り結果のチェック」時に、該当箇所の解答欄が赤枠で囲まれて表示されますので、マークの状態をヒトの眼で確認し、読み取り結果を修正できます。

最も困るのが、複数マークされた解答欄の「10の位」のマークは薄くて読めなかったが、「1の位」のマークの読み取りには成功している場合です。この場合は正しく読み取れた「1の位」のマークが読み取り結果として表示されてしまいます。大変申し訳ないのですが、MS_Reader.exe のチェックプログラムは、この誤読を見つけることができません!!(これは原理的な問題なので、チェックする方法がありません)

この事故を防ぐには、事前に読み取りテストを十分に行って、読み取りパラメータを調整するしか方法がありません。具体的には、最もマークの濃度が薄い受験者のマークシートが正しく読めるようになるまで、閾値の値を1ずつ大きくして読み取りテストを実行します。筆者の行ったテストでは、デフォルト設定の閾値(180)では正しく読めなかったマークも、閾値を大きくすれば読めるようになりました。

「塗りつぶす」方式でなく、より簡易な「線を引く」方式でマークするシートを用いて行った読み取りテストの例

「64」と読むべき箇所の「10の位」を「空欄」と判定、「1の位」は正しく読めた場合、プログラムは読み取り結果を「4」と表示してしまう。


閾値を「1」大きくすると・・・

正しく読めるようになりました。


ごく薄いマークも正しく読めるようになるまで、この操作をくり返します。筆者の行ったテストでは、閾値を1ずつ大きくすることで、指示通り普通の濃さ(マーク箇所の数字が読めなくなる濃さ)でマークされたシートであれば、最終的にすべてのマークを正しく読めるようになりました(上記のマークシートを使用して行った実験では、最終的にすべてのマークを正しく読めた段階で、二値化の閾値の値は「184」でした。なお、このマークシートは後日公開する予定です)。

ただ、あまりにもマークの濃さが薄い場合は「原理的に読めません」ので、「対応不可」としてヒトの眼で読むしかないと思います・・・。

そのような事態にならないよう、予め受験者に対し「薄いマークは読み取れない」旨の注意を徹底しておく必要があります。マークシートを用いた試験では、これが最も重要なことかもしれません。


【参考】Grid コントロールの高さを変更する方法

Grid コントロールの高さは自由に変更できます。マークシート画像と Grid コントロールの境界部分にマウスカーソルを持って行く(ポイントする)と、=の上と下に上下向きの矢印のついたポインタ形状に変化する場所があります。この部分を上下にドラッグすることで、Grid コントロールの高さを変更することができます。

Grid コントロールの高さを1行分にしたところ

7.マーク読み取り結果のチェック方法

(1)マークの読み取りが完了したら、結果をチェックします。設問数が100である場合はそのまま「Check!」ボタンをクリックしてください。

Check! ボタンをクリックして、読み取り結果を確認します。


設問数が100 未満の場合、空欄その他の判定フラグである「999」が多数入力されていますので、チェック時にそれらのチェックを省略する設定を行います。

例えば、設問数が「80」である場合、4列目の設問「81」以降の「999」はチェックの必要性がありませんから、Skip チェックボックスにチェックして、4列目のコンボボックスに選択肢から「81」を選択します。「覚」ボタンをクリックすると、この設定を記憶します(他のクラス/講座のマークシートを読み取る場合は記憶させてください)。この設定を行ってから、Check! ボタンをクリックして読み取り結果のチェックを実行してください。


(2)読み取り結果に問題があると判定された場合は、次のように「問題あり」と判定された箇所が赤い矩形で囲まれて表示されます。(この場合は、マークが横に長すぎて隣のマークの領域に入ってしまっているのだと思います)

実際の画面では、14 設問目の「999」は青く反転表示されています。
(画面をキャプチャーした際、青の反転表示が消えてしまいました)


確認を行った結果、マークが「15」であり、正解と見なせる場合は Grid コントロールの「999」を消して「15」と入力し、読み取り結果を修正することができます。

読み取り結果を「15」に変更したところ


ちなみにこのような場合、「P4Dを使用」のチェックを外し、判定領域をマーク画像の中心付近のみに設定して読み直せば正しく読めるようになります。(ただし、動作速度は遅くなります)


読み取りパラメータを上のように変更して再度マーク読み取りを実行した場合、

「999」表示は消え、さっきは読めなかったマークを正しく読むことができました。


(3)再度「Check!」ボタンをクリックして、チェックを続行します。

10の位に二つマークがある場合等、不正解と見なせる場合は、「999」を修正せず、チェックを続行します。
続けて「Check!」をクリックしてください。


次のメッセージが表示されたらチェックは完了です。

8.CSV形式で出力

マークの読み取り結果の確認作業が完了したら、Grid コントロールに表示されているデータをCSV形式でファイルに出力できます。このCSVファイルは筆者作の採点結果通知作成プログラムから読み込んで採点結果通知票の作成に利用できます。Excel Book にもこのデータを書き出すことができますが、複数マーク対応の採点結果通知を作成できる Excel のワークシートは作成しておりませんので、ここではその処理方法の詳細は説明しません。

CSV形式で出力する方法は、次の通りです。

画面右下の「ファイルへ出力」グループの CSV オプションボタンをクリックして選択し、「書き出し」ボタンをクリックします。


書込みが完了すると、次のメッセージが表示されます。


表計算ソフトその他を用い、ご自身で採点結果を処理される場合は、出力先をメモしてください。

出力されたCSVの内容は、次の通りです。

1行目は「設問番号」、A列が「マークシート番号(=出席番号)」です。

9.採点結果通知シートの作成方法

採点結果通知の作成方法は、当Blogの過去記事をご参照下さい。

10.お願いとお断り

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