2026 年 9 月 1 日更新: pip install opencv-python が入れるバージョンが 5 系に切り替わったため、全コードを 5.0.0.93 と 4.12.0.88 の両方で動かして検証し直しました。あわせて「検査タイプ別の早見表」「ラベリング」「マスター画像が不要な Black Hat での傷検出」「位置ズレ補正」「Windows のカメラ起動時間の実測」「OpenCV 5 で消えた機能」を新設し、出典の無い数字(「撮影条件が 80%」など)は測れる記述に置き換えました。
1. まず決めるのは手法ではなく「検査タイプ」— 手法選定の早見表
外観検査で最初に詰まるのは、たいてい実装ではありません。「何を不良と呼ぶのか」——つまり良品と不良品の境界が言葉になっていない状態で書き始めると、どの関数を使っても判定がぶれます。逆に、判定したいものが 1 文で言えれば、使う関数はほぼ自動的に決まります。
| 検査タイプ | 判定したいこと | 主に使う関数 | 事前に要るもの | 本記事 |
|---|---|---|---|---|
| A: 有無・個数・寸法 | 部品が付いているか、穴が何個あるか、外形が規格内か | threshold → findContours / connectedComponentsWithStats | 背景と対象のコントラストだけ(正常品の画像は不要) | 6 章 |
| B: 位置・形状の照合 | 決まった形が、決まった場所にあるか | matchTemplate → minMaxLoc → 重複除去 | テンプレート画像 1 枚と、姿勢がほぼ一定であること | 7 章 |
| C: 傷・欠陥 | 線傷・打痕・汚れなど「あってはならないもの」 | ① absdiff(マスター差分)② morphologyEx の Black Hat / Top Hat | ①は正常品の画像と厳密な位置合わせ ②は何も要らない | 8 章 |
| (該当なし) | 不良の見本を言葉でも図でも定義できない | 異常検知・物体検出(学習系) | 学習用の画像枚数と GPU、閉域網なら持ち込み手段 | 12 章 |
扱わない領域も先に書いておきます。量産ラインでの検査保証、タクトタイムの担保、ベンダーサポートが要る場面は専用機の領分です。専用機は照明・レンズ・判定ロジックがセットで最適化されており、そこを自作で置き換える話ではありません。ここで作るのは「その専用機に何を要求すべきかを決めるための、手元で回る試作」です(内製化の進め方も参考にしてください)。
2. 環境構築 — pip install opencv-python が 5 系を入れる時代の注意
python -m pip install opencv-python numpy
パッケージ名は opencv-python、import 名は cv2 です(pip install cv2 は存在しないパッケージ名で、失敗します)。社内プロキシで pip が通らない場合は 学習ロードマップ STEP 2 を参照してください。
ここからが 2026 年の変更点です。PyPI の opencv-python は 2026-07-02 に 5.0.0.93 が公開され、いま pip install opencv-python をそのまま打つと 5 系が入ります(新規の仮想環境で確認。cv2.__version__ は 5.0.0、pip show の表示は 5.0.0.93)。4 系のつもりで書いた既存コードがあるなら、まず何が入っているかを確認してください。
import cv2
print(cv2.__version__) # 5.0.0
print(cv2.getBuildInformation()[:200]) # ビルド構成まで見たいとき
2.1 4 系で止まるか、5 系に上げるか — ピン留めの実務
一方で4 系は終わっていません。PyPI のアップロード日時で見ると 4 系の最新 wheel は 4.14.0.94(2026-07-29)で、5.0.0.93(2026-07-02)より後に出ています。選択肢は「5 に上げる」か「4 で止まる」かの二択ではなく、4 系をピン留めして再現性を確保しつつ、5 系は別の仮想環境で試すのが現実的です。
# 現場の実行環境: 4 系をピン留め(バージョンを固定して再現性を確保)
python -m pip install "opencv-python==4.14.0.94"
# 検証用: 別の仮想環境を作って 5 系を入れ、同じ画像で結果を突き合わせる
python -m venv .venv-cv5
当サイトもこの方法で 5.0.0.93 と 4.12.0.88 の 2 環境を作り、同じ画像に同じコードを流しました。結論を先に書くと、本記事に出てくる画像処理の数値結果(二値化のしきい値、輪郭の座標と面積、マッチングのスコア、特徴点のマッチ数)は 2 つのバージョンで 1 つも違いませんでした。壊れるのは画像処理ではなく、次の表の「消えた機能」とカメラ周りです。
| 確認した項目 | 4.12.0.88 | 5.0.0.93 | 外観検査への影響 |
|---|---|---|---|
threshold/findContours/matchTemplate/morphologyEx の結果 | 完全に一致(当サイト実測) | 影響なし | |
findContours の戻り値の個数 | 2 個 | 2 個 | 3 系の 3 個から変わったまま。contours, hierarchy = ... でよい |
cv2.ml(SVM 等) | 使える | 無い | 本体から分離。学習系は scikit-learn などへ |
cv2.dnn.readNetFromDarknet / readNetFromCaffe | ある | 無い | YOLO の .weights を直接読む書き方は不可。ONNX 変換が前提 |
cv2.dnn.ENGINE_CLASSIC | 無い | ある | 5 では新エンジンが既定。旧挙動に戻す指定ができる |
cv2.CascadeClassifier(Haar) | ある | 無い | contrib 送り。顔・人検出は cv2.FaceDetectorYN など DNN 系へ |
cv2.CAP_PROP_UNKNOWN | 未定義 | -1 | 10 章。カメラ設定の確認コードに影響 |
cv2.SIFT_create / cv2.ORB_create | どちらもある | 影響なし(内部モジュール名は変わったが Python からは同じ) | |
2.2 opencv-python は 4 パッケージある — 入れてよいのは 1 つだけ
4 パッケージのうち入れてよいのは 1 つだけです。opencv-python(標準)/opencv-contrib-python(contrib 込み)/opencv-python-headless(GUI 無し)/opencv-contrib-python-headless はすべて同じ cv2 という名前空間を使うため、公式も「複数入れてしまったら全部 pip uninstall してから 1 つだけ入れ直せ」と明記しています。cv2.imshow を使わない場合や、Tkinter など OpenCV 以外で画面を作る場合は headless 版を選びます(公式の選択基準)。Windows サービスとして常駐させる検査アプリは後者に当たります。
なお当サイトの自宅検証機(Smart App Control 有効)では、4.14.0.94 を入れると import の時点で ImportError: DLL load failed while importing cv2: アプリケーション制御ポリシーによってこのファイルがブロックされました。 となりました。OpenCV の不具合ではなく PC 側の実行制御設定によるものです。同様に、セキュリティ設定の固い業務 PC でも「pip は成功したのに import で落ちる」形で現れます。本記事の 4 系側の検証はこのため 4.12.0.88 で行っています。
結局どれを打つかを 3 択で置いておきます。
- 新規に始める/4 系にこだわらない →
python -m pip install opencv-python(5.0.0.93) - 既存の 4 系コードを動かし続ける →
python -m pip install "opencv-python==4.14.0.94" - それが import で落ちる(実行制御の固い PC) →
python -m pip install "opencv-python==4.12.0.88"
3. サンプル画像を自作する(カメラ不要・閉域網でも再現できる)
本記事のコードで使う画像は、すべて次のスクリプトで自作できます。カメラも素材サイトも要らないので、外部ネットワークにつながらない工場の PC でも全章を再現できます。まずこれを実行してください。
# gen_images.py — 本記事の全章で使うサンプル画像を生成する
import cv2
import numpy as np
# 正常品(マスター): 黒背景に明るい金属部品風の矩形と、中央の穴
good = np.zeros((400, 600), np.uint8)
cv2.rectangle(good, (150, 100), (450, 300), 200, -1) # 部品本体(明るいグレー)
cv2.circle(good, (300, 200), 40, 0, -1) # 中央の穴(黒)
cv2.imwrite("good.png", good)
# 傷あり品: 正常品に細い傷(暗い線)を 2 本足す
target = good.copy()
cv2.line(target, (200, 130), (280, 180), 60, 3) # 傷 1
cv2.line(target, (350, 250), (420, 270), 60, 2) # 傷 2
cv2.imwrite("target.png", target)
# 照明ムラ + ノイズ入り: 左上が明るく右下が暗い、背景にも光が回り込んだ版
yy, xx = np.mgrid[0:400, 0:600].astype(np.float32)
shade = 1.0 - 0.6 * (xx / 1200.0 + yy / 800.0)
base = target.astype(np.float32)
base[base == 0] = 40 # 背景にも環境光が乗っている
rng = np.random.default_rng(0) # 種を固定して毎回同じ画像にする
uneven = np.clip(base * shade + rng.normal(0, 4.0, target.shape), 0, 255).astype(np.uint8)
cv2.imwrite("uneven.png", uneven)
# 位置ズレ品: 傷あり品を 5 度回転し、少し平行移動した版
M = cv2.getRotationMatrix2D((300.0, 200.0), 5.0, 1.0)
M[0, 2] += 12
M[1, 2] += 7
cv2.imwrite("target_rot.png", cv2.warpAffine(target, M, (600, 400)))
# テンプレート(穴のまわりを 80x80 で切り出し)
cv2.imwrite("template.png", good[160:240, 260:340])
print("good.png / target.png / uneven.png / target_rot.png / template.png を生成しました")
python gen_images.py で、実行したフォルダに 5 枚の PNG が出ます。PNG は可逆圧縮なので、以降に出てくる画素数の実測値がそのまま再現します(JPEG に変えると圧縮の副作用で数 px ずれます)。
ここで大原則を 1 つ。OpenCV の輪郭抽出は「黒い背景から白い物体を見つける」処理として作られており、探したい対象が白(明るい)、背景が黒(暗い)である前提です。モルフォロジー処理の公式チュートリアルにも「前景は常に白に保つようにせよ」とあります。上のスクリプトが黒背景に明るい部品を描いているのは偶然ではありません。明るい背景に暗い対象を撮ってしまった場合は、二値化を THRESH_BINARY_INV にして白黒を反転させてから先に進みます。ここを外したまま findContours を呼ぶと、画像全体が 1 つの対象として検出されます。
4. 画像の読み書きで詰まる 4 か所 — None・BGR・EXIF 回転・日本語パス
import cv2
import numpy as np
img = cv2.imread("target.png") # BGR 順の numpy 配列が返る
if img is None: # 読めなくても例外は出ない
raise FileNotFoundError("target.png が見つかりません(3 章の gen_images.py を先に実行)")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
以降の各章のコードは、この img・gray が定義済みである前提の断片です。単独のファイルで試すときは、上のコードを先頭に貼ってから続けてください。詰まりどころは次の 4 つに集約されます。
| 症状 | 原因 | 対処 |
|---|---|---|
cv2.error: (-215:Assertion failed) !_src.empty() | 前段の imread が None を返している | 読み込み直後に if img is None で止める(下記) |
| 色がおかしい(赤と青が入れ替わる) | OpenCV は BGR 順。Matplotlib や Pillow は RGB | 表示前に cv2.cvtColor(img, cv2.COLOR_BGR2RGB) |
| 画像の縦横が勝手に入れ替わる | JPEG の EXIF 回転が自動適用される | cv2.IMREAD_IGNORE_ORIENTATION を付ける(下記) |
| 日本語フォルダに置いた画像だけ読めない/保存できない | Windows の非 ASCII パス問題(未解決の既知不具合) | imdecode / imencode 経由にする(下記) |
読めないときに例外が出ないのは仕様です。公式の imgcodecs リファレンスは「ファイルが無い、権限が足りない、対応していない形式などで画像を読めない場合、この関数は空の行列を返す」と書いており、Python では None になります。パスの誤りに気づくのは None を次の処理に渡した瞬間で、出るのは上表の !_src.empty() という一見無関係なメッセージです。このエラーを見たら、まずファイルパスを疑ってください。
4.1 EXIF 回転 — 検査画像で最も気づきにくい罠
同じ imgcodecs リファレンスには、EXIF 情報が埋め込まれている場合はその回転指示に従って画像を回転させると明記されています。スマートフォンや一部のカメラが吐く JPEG には、この情報が入っています。Orientation=6(90 度回転)を埋め込んだ 400×600 の JPEG を読ませた実測が次です。
a = cv2.imread("photo.jpg", cv2.IMREAD_GRAYSCALE)
print(a.shape) # (600, 400) ← EXIF に従って回転されている
b = cv2.imread("photo.jpg", cv2.IMREAD_GRAYSCALE | cv2.IMREAD_IGNORE_ORIENTATION)
print(b.shape) # (400, 600) ← ファイルに入っている生の向き
怖いのは、これがエラーにならず、静かに座標系だけをずらすことです。テンプレートマッチングの座標も、マスターとの差分も、寸法の縦横も入れ替わります。検査画像は原則 PNG で保存し、既存の JPEG を読む処理には IMREAD_IGNORE_ORIENTATION を付けて向きを固定します。
4.2 日本語パスは今も読めない
Windows で日本語を含むパスの画像が読めない問題は、OpenCV の Issue #18305 として 2020 年に起票され、2026 年 9 月時点でも未解決(対応予定は 5.1)です。当サイトで 5.0.0.93 と 4.12.0.88 の両方を確認したところ、どちらも同じ挙動でした。
cv2.imwrite("検査画像.png", gray) # False が返り、ファイルは作られない(例外は出ない)
cv2.imread("検査画像.png") # None が返る
読み込み時には cv::findDecoder imread_('検査画像.png'): can't open/read file: check file path/integrity という警告も出ますが、あくまで警告で、プログラムは None を持ったまま走り続けます。回避策は、ファイルの入出力を Python 側で行い、OpenCV にはメモリ上のバイト列だけを渡すことです。
def imread_unicode(path: str, flags: int = cv2.IMREAD_COLOR):
with open(path, "rb") as f: # 読み込みは Python に任せる
data = np.frombuffer(f.read(), np.uint8)
return cv2.imdecode(data, flags) # OpenCV にはバイト列を渡す
def imwrite_unicode(path: str, img) -> bool:
ext = path[path.rfind("."):]
ok, buf = cv2.imencode(ext, img) # メモリ上でエンコード
if not ok:
return False
with open(path, "wb") as f:
f.write(buf.tobytes())
return True
当サイトの実測では、この関数経由なら日本語ファイル名でも 2,498 バイトの PNG が正しく書け、(400, 600) の配列として読み戻せました。NG 画像を「品番_日時」で日本語混じりに保存する運用は現場でよくあるので、保存側にも同じ対策が要ります(文字コード全般は cp932 と UTF-8 の対処を参照)。以降のサンプルは英数字パス前提で素の cv2.imread を使います。
5. 前処理と二値化 — しきい値 4 手法の選び方
外観検査の処理は、ほぼ例外なく次の並びになります。この順番を覚えてしまえば、あとは各段で使う関数を差し替えるだけです。
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0) # 大津の前にノイズを落とすのは公式チュートリアルの定石
# ① 固定しきい値: 照明が安定しているならこれで十分(最速・結果が読める)
t1, binary = cv2.threshold(gray, 128, 255, cv2.THRESH_BINARY)
# ② 大津: しきい値を画像から自動決定。返り値の t2 が「今回選ばれた値」
t2, otsu = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
# ③ トライアングル: 背景のピークが 1 つで前景が薄いときの自動決定
t3, tri = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_TRIANGLE)
# ④ 適応しきい値: 位置ごとに別のしきい値。ムラに強いが背景ノイズも拾う
adaptive = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, blockSize=51, C=2)
print(t1, t2, t3) # target.png では 128.0 60.0 2.0
| 手法 | 向いている場面 | 注意点 |
|---|---|---|
| 固定しきい値 | 照明・露出を固定できるライン。値をログに残せば原因追跡が楽 | 照明が変わると即破綻する。当サイト実測でも照明ムラ画像で輪郭が 1 個から 370 個に増えた |
大津(THRESH_OTSU) | 背景と対象の明るさが 2 つの山に分かれている画像。最初の候補 | 公式も「2 つの明確な値を持つ画像」を前提と説明。山が 1 つだと外れる |
トライアングル(THRESH_TRIANGLE) | 背景の山が 1 つで、前景がごく薄いとき | 今回の合成画像では 2.0 や 48.0 と低い値を選び、ノイズまで白にした |
| 適応しきい値 | 1 枚の中で明るさが大きく変わり、対象が画面いっぱいにあるとき | blockSize は奇数。背景が広い画像ではノイズを拾って輪郭が激増する |
ここで、多くの記事が捨てている値を拾ってください。cv2.threshold の第 1 戻り値は「実際に使われたしきい値」です。自動決定に任せた場合、この値は撮影のたびに変わります。実測でも、照明ムラのある uneven.png で 60.0、CLAHE で補正してから大津にかけると 93.0、ブラーを挟むと 83.0 と動きました。この 1 つの数値を判定ログに残すと、「今日から NG が増えた」ときに照明が変わったのかワークが変わったのかを、画像を見返さずに切り分けられます(ログ設計で扱う「後から原因を追える情報を残す」の具体例です)。
t, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
logger.info("otsu_threshold=%.1f", t) # 毎ショット記録する。異常検知より先に効く
コントラストが足りないときの補正も 2 行です。equalizeHist は画像全体を、createCLAHE はタイルごとにヒストグラムを平坦化するので、外乱光でムラがある画像には後者が向きます。
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
fixed = clahe.apply(gray)
t, binary = cv2.threshold(fixed, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
6. 検査タイプ A: 有無・個数・寸法 — 輪郭抽出とラベリング
「部品が付いているか」「穴が何個あるか」「外形が規格内か」を見る、いちばん出番の多い検査です。二値化した画像から対象の領域を取り出す方法は 2 系統あり、どちらを選ぶかで得られる情報が変わります。
findContours | connectedComponentsWithStats | |
|---|---|---|
| 返すもの | 輪郭(境界の点列)と階層 | ラベル画像・外接矩形・面積・重心をまとめた配列 |
| 面積の意味 | contourArea は輪郭が囲む多角形の面積(内側の穴も面積に含む) | CC_STAT_AREA は実際の画素数(穴は含まない) |
実測(good.png の部品) | 60,000 | 55,476(= np.count_nonzero と一致) |
| 形の解析 | 得意(minAreaRect・approxPolyDP・凸包など) | できない(矩形と面積と重心のみ) |
| 個数を数える | ループを回して数える | 戻り値の個数から背景 1 個を引くだけ |
| 向いている用途 | 寸法測定・形状判定 | ブロブ(かたまり)の個数と大きさの選別 |
差の 4,524 px は、穴の 5,025 px(6.3 で実測)から 501 px を引いた値です。部品の実画素数は 301×201 = 60,501 px ですが contourArea は多角形として 300×200 = 60,000 px を返すためで、6.1 の「幅が 301」と同じ 1 画素の定義の話です。「面積で良否を判定する」と決めた後で、どちらの面積を使っているのかを間違えると、穴の有無を検出できない検査ができあがります。
6.1 輪郭で寸法を測る
t, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for cnt in contours:
area = cv2.contourArea(cnt)
if area < 100: # ノイズ除外。撮影解像度と対象サイズに合わせて調整
continue
x, y, w, h = cv2.boundingRect(cnt)
print(f"object: x={x} y={y} w={w} h={h} area={area:.1f}")
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imwrite("result.png", img)
3 章の画像では object: x=150 y=100 w=301 h=201 area=60000.0 と 1 個だけ検出されます(両バージョンで同一)。指定した矩形は 150〜450 なので幅 300 のはずですが、返るのは 301 です。boundingRect は端の画素を含めて数えるためで、ミリに換算する前にこの 1 画素の定義を確認しておかないと公差の議論が噛み合いません。
実寸への換算は、既知サイズの校正用治具を同じ条件で撮ってピクセル数を数えるのが基本です。ただし精度には限界があります。レンズの歪み(cv2.calibrateCamera で補正可能)、対象の高さによる視差、画角の周辺ほど像が伸びる問題があるため、測るのは視野の中心に限り、公差の厳しい精密寸法はテレセントリックレンズを積んだ専用機の領分だと割り切ってください。
6.2 穴を検査するなら RETR_EXTERNAL では取れない
findContours の第 2 引数(取得モード)は既定のように RETR_EXTERNAL と書かれがちですが、公式の説明では「いちばん外側の輪郭だけを取得する」モードで、部品の内側にある穴は返ってきません。穴を検査するなら RETR_CCOMP(外周と穴の 2 階層)か RETR_TREE(入れ子を全階層)を使います。
contours, hierarchy = cv2.findContours(binary, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)
for i, cnt in enumerate(contours):
parent = hierarchy[0][i][3] # -1 なら外周、それ以外なら「穴」
x, y, w, h = cv2.boundingRect(cnt)
kind = "外形" if parent == -1 else "穴"
print(f"{kind}: x={x} y={y} w={w} h={h} area={cv2.contourArea(cnt):.0f}")
good.png での実測は次のとおりです。RETR_EXTERNAL では 1 個しか返らなかったところ、RETR_CCOMP では穴が第 2 階層として取れます。
外形: x=150 y=100 w=301 h=201 area=60000
穴: x=259 y=159 w=83 h=83 area=5138
穴の直径を 83 px として設計寸法と突き合わせれば、「穴あけ忘れ」だけでなく「穴径の異常」も同じコードで拾えます。
6.3 ラベリングで個数と大きさを一気に取る
n, labels, stats, centroids = cv2.connectedComponentsWithStats(binary)
print(n, labels.shape, labels.dtype, stats.shape, centroids.dtype)
# 2 (400, 600) int32 (2, 5) float64
for i in range(1, n): # ラベル 0 は背景なので 1 から
x, y, w, h, area = stats[i]
cx, cy = centroids[i]
print(f"label {i}: x={x} y={y} w={w} h={h} area={area} 重心=({cx:.1f}, {cy:.1f})")
# label 1: x=150 y=100 w=301 h=201 area=54843 重心=(300.2, 200.1)
# (傷のある target.png での値。傷の無い good.png なら 55476)
戻り値は 4 つで、stats の各行は [左端 x, 上端 y, 幅, 高さ, 画素数] の 5 列です(列の意味は cv2.CC_STAT_LEFT などの定数で参照できます)。「面積 500 px 以上のかたまりを数える」程度の選別なら、輪郭をループで回すより読みやすくなります。
落とし穴が 1 つ。ラベル 0 は「画像内のゼロ画素すべて」で、部品の中の穴も背景として一括りにされます。実測でも、部品と穴がある画像でラベル数は 2(背景と部品)にしかなりませんでした。穴の個数を数えたいときは、二値画像を反転してからラベリングします。
n, labels, stats, centroids = cv2.connectedComponentsWithStats(cv2.bitwise_not(binary))
for i in range(1, n):
x, y, w, h, area = stats[i]
if w >= binary.shape[1] or h >= binary.shape[0]:
continue # 画像の外周に接する領域=部品の外側なので除く
print(f"穴: x={x} y={y} w={w} h={h} area={area}")
# 穴: x=260 y=160 w=81 h=81 area=5025 ← good.png で実行した場合
半径 40 px の円の理論面積 5,026 px に対して実測 5,025 px。穴を「面積 5,000 px 前後・1 個」と規定すれば、そのまま良否判定の条件になります。
7. 検査タイプ B: 位置と形状の照合 — テンプレートマッチングと重複除去(NMS)
「決まった形が、画像のどこに何個あるか」を探す手法です。刻印の有無、部品の組み付け位置、コネクタの挿し忘れなどに使えます。テンプレート画像をスライドさせながら各位置での一致度を計算し、その一致度マップを返すだけの、素直な処理です。
template = cv2.imread("template.png", cv2.IMREAD_GRAYSCALE)
res = cv2.matchTemplate(gray, template, cv2.TM_CCOEFF_NORMED)
print(gray.shape, template.shape, res.shape)
# (400, 600) (80, 80) (321, 521)
戻り値は画像ではなくスコアの配列です。サイズは公式ドキュメントのとおり「(入力の高さ − テンプレートの高さ + 1, 入力の幅 − テンプレートの幅 + 1)」になります(実測でも 400−80+1=321、600−80+1=521)。ここを画像だと思って imshow すると座標がずれます。
min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(res)
print(max_val, max_loc) # 0.9833 (260, 160)
対象が必ず 1 個なら、minMaxLoc で最良位置を取るだけで終わりです。実測では、テンプレートを切り出した元の座標(260, 160)がスコア 0.9833 で 1 位になりました。1.0 にならないのは、切り出し元の good.png に対して傷のある target.png を探索しているためです。使う指標によって「最良」の向きが変わる点にも注意が要ります。TM_CCOEFF_NORMED や TM_CCORR_NORMED は最大値が最良ですが、TM_SQDIFF 系は公式が明記しているとおり最小値が最良です。
複数個を探すなら、しきい値以上の位置を全部拾います。ただしそのままでは、1 個の対象の周りで隣接ピクセルが軒並みヒットし、矩形が何重にも描かれます。実測では 12 か所がヒットしました。実務ではここを重複除去(NMS)でまとめます。
threshold = 0.85 # 見逃すなら下げ、誤検出が出るなら上げる(0.7〜0.95 で調整)
h, w = template.shape
boxes = [(int(x), int(y), w, h, float(res[y, x]))
for y, x in zip(*np.where(res >= threshold))]
def nms(boxes, iou_thresh=0.3):
"""スコアの高い順に残し、重なりすぎた矩形を捨てる"""
keep = []
for b in sorted(boxes, key=lambda b: -b[4]):
overlapped = False
for k in keep:
iw = max(0, min(b[0] + b[2], k[0] + k[2]) - max(b[0], k[0]))
ih = max(0, min(b[1] + b[3], k[1] + k[3]) - max(b[1], k[1]))
inter = iw * ih
if inter / (b[2] * b[3] + k[2] * k[3] - inter) > iou_thresh:
overlapped = True
break
if not overlapped:
keep.append(b)
return keep
hits = nms(boxes)
print(len(boxes), "->", len(hits)) # 12 -> 1
for x, y, w, h, score in hits:
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 0, 255), 2)
テンプレートマッチングはサイズ・回転・照明の変化に弱い手法で、対象が数度傾いただけでスコアが落ちます。姿勢が動く前提なら、治具で固定するか次章の方法を検討してください。処理時間は 600×400 の画像に 80×80 のテンプレートで 4.15 ms/枚(当サイト実測・200 回平均)。テンプレートを大きくすると急に重くなるので、探す範囲を gray[y1:y2, x1:x2] で絞るのが定石です。
8. 検査タイプ C: 傷・欠陥 — マスター差分と、マスター不要の Black Hat
形も位置も決まっていない「あってはならないもの」を見つける検査です。方法は 2 つあり、正常品の画像(マスター)が要るかどうかで性格がまったく違います。
8.1 マスター差分(absdiff)
good = cv2.imread("good.png", cv2.IMREAD_GRAYSCALE)
target = cv2.imread("target.png", cv2.IMREAD_GRAYSCALE)
diff = cv2.absdiff(good, target)
_, mask = cv2.threshold(diff, 30, 255, cv2.THRESH_BINARY) # 30: 照明変動で誤検出するなら上げる
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) # 小さなノイズを除く
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask)
areas = [int(stats[i][4]) for i in range(1, n)]
print(f"欠陥 {n - 1} 個 面積 {areas} 合計 {int(np.sum(mask > 0))} px")
# 欠陥 2 個 面積 [479, 201] 合計 680 px
2 本引いた傷が 2 個のかたまり・合計 680 px で検出されました(ノイズ除去前は 700 px)。合計面積だけで OK/NG を決めるより、ラベリングして「1 個あたりの最大面積」も見るほうが実用的です。小さな汚れが 10 個ある状態と長い傷が 1 本ある状態は、合計面積が同じでも意味が違います。
8.2 マスターが要らない Black Hat
モルフォロジー演算の Black Hat は、公式の定義では「クロージング処理した画像から元画像を引いたもの」です。クロージングはカーネルより小さい暗い隙間を埋めるので、その差分を取ると「カーネルより細い暗い部分」だけが残ります。つまり、背景の明るさがどうであろうと、細い傷だけが浮きます。正常品の画像も、位置合わせも要りません。
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 15)) # 拾いたい傷の幅より大きく
blackhat = cv2.morphologyEx(target, cv2.MORPH_BLACKHAT, kernel)
t, mask = cv2.threshold(blackhat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask)
for i in range(1, n):
x, y, w, h, area = stats[i]
print(f"傷: x={x} y={y} w={w} h={h} area={area}")
# 傷: x=199 y=129 w=69 h=45 area=399
# 傷: x=352 y=250 w=67 h=21 area=201
3 章で引いた 2 本の傷の位置(200,130)〜(280,180)と(350,250)〜(420,270)に、検出された矩形がほぼ重なっています(Black Hat とオープニングで先端が数 px 削れ、矩形は実際の傷よりわずかに小さく出ます)。正常品の画像を 1 枚も使わずに、傷の位置と大きさが出ました。
両者の差が決定的になるのは、照明が変わったときです。同じ部品・同じ傷で、照明にムラがある uneven.png を検査した結果を並べます。
Black Hat 側は、カーネルサイズを 9・15・21・31 と変えても同じ 2 個・同じ面積でした。傷の幅(2〜3 px)よりカーネルが十分大きければ結果が安定します。正常品の good.png に同じ処理をかけた検出数は 0 個で、誤検出も出ていません。処理時間は 0.15 ms/枚(15×15・200 回平均)です。
もちろん万能ではありません。Black Hat が拾えるのは「カーネルより細い、周囲より暗い」欠陥だけです。周囲より明るい異物は Top Hat(MORPH_TOPHAT=元画像からオープニングを引いたもの)で拾えますが、当サイトで試した範囲では穴のふちも一緒に反応したため、誤検出の切り分けが要ります。部品の欠けのような面積の大きい欠陥は原理的に拾えません。そこはマスター差分か、6 章の面積判定の担当です。
| マスター差分 | Black Hat | |
|---|---|---|
| 事前に要るもの | 正常品の画像と、毎回同じ姿勢・同じ照明 | なし |
| 拾える欠陥 | マスターとの違いすべて(欠け・変形・位置ずれも) | カーネルより細い、周囲より暗い欠陥 |
| 照明が変わったとき | 全面が差分になる(実測 81,717 px) | 結果が変わらない(実測 600 px のまま) |
| 位置合わせ | 必須(9 章) | 不要 |
| 最初に試すなら | 治具でワークを固定できるライン | 手置き・多品種で姿勢が安定しない工程 |
9. 位置ズレを吸収する — ORB が効かない対象で何を使うか
マスター差分は位置合わせが命です。多くの解説は「特徴点マッチング(ORB)で位置を合わせてから差分を取る」と書きますが、当サイトで実際に通しで試したところ、工業部品のような模様の少ない対象では ORB が破綻しました。順に数字を出します。
まず ORB 自体は動きます。good.png と target.png で 91 組、5 度回転させた target_rot.png とは 30 組のマッチが取れました。SIFT の特許(US6711293)は 2020 年 3 月に失効し OpenCV 本体に入っていますが、ORB はバイナリ特徴で軽いため、リアルタイム用途では今も第一候補です(実測 2.73 ms/枚)。
good = cv2.imread("good.png", cv2.IMREAD_GRAYSCALE) # 正常品(マスター)
rot = cv2.imread("target_rot.png", cv2.IMREAD_GRAYSCALE) # 3 章で作った 5 度回転版
orb = cv2.ORB_create(nfeatures=2000)
kp1, des1 = orb.detectAndCompute(good, None)
kp2, des2 = orb.detectAndCompute(rot, None)
if des1 is None or des2 is None:
raise RuntimeError("特徴点が検出できません(模様の少ない画像では起きえます)")
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = sorted(bf.match(des1, des2), key=lambda m: m.distance)
print(len(matches)) # 30
問題はこの先です。マッチから findHomography で変換行列を求め、warpPerspective で補正して差分を取る——という定番の流れを実行した結果が次です。補正しない状態の差分面積は 12,622 px(回転しているので当然大きい)でした。
| 方法 | 使ったマッチ数 | RANSAC のインライア | 補正後の差分面積 | 判定 |
|---|---|---|---|---|
| 補正なし | — | — | 12,622 px | 基準 |
| ORB + findHomography | 上位 10 組 | 5 | 65,769 px | 悪化 |
| ORB + findHomography | 上位 20 組 | 6 | 74,845 px | 悪化 |
| ORB + findHomography | 30 組すべて | 2 | 231,219 px | 破綻 |
findTransformECC | —(画素値を直接最適化) | — | 838 px | 成功 |
ORB は模様(テクスチャ)のある対象の位置合わせには強いが、無地の板や単色の樹脂部品には特徴点が乗らないため、正しいマッチが足りずホモグラフィが暴れます。インライアが 2 組しか残らない状態で 8 自由度の変換を推定すれば、当然こうなります。代わりに使えるのが findTransformECC で、これは特徴点を使わず画素値そのものを合わせ込む方式です。
# 平行移動 + 回転(4 自由度)に限定して合わせ込む
warp = np.eye(2, 3, dtype=np.float32)
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 500, 1e-6)
cc, warp = cv2.findTransformECC(good.astype(np.float32) / 255,
rot.astype(np.float32) / 255,
warp, cv2.MOTION_EUCLIDEAN, criteria, None, 5)
aligned = cv2.warpAffine(rot, warp, (good.shape[1], good.shape[0]),
flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP)
print(cc, np.degrees(np.arctan2(warp[1, 0], warp[0, 0]))) # 0.9974 -5.00
相関係数 0.9974、推定された回転角は −5.00 度(gen_images.py で与えた 5 度と一致)、所要 41 ms。補正後に差分を取ると 838 px・2 個となり、位置ズレの影響がほぼ消えて傷 2 本だけが残りました(補正なしの 680 px より少し大きいのは、回転補間で輪郭が滲むためです)。
ただしこれはズレが小さい(数度・数十 px)ときの補正です。findTransformECC は初期値から反復して合わせ込むので、大きくずれていると収束しません。大きなズレは、ソフトで吸収するより治具でワークの位置を決めてしまうほうが、結果が安定します(当サイトでは治具側のコストは計測していません)。ソフト側の補正は、治具で残った数 px の遊びを潰すために使ってください。なお OpenCV 5 の移行ガイドは warpAffine / warpPerspective の結果が 4.x とわずかに変わると明記しているので、補正後の面積でしきい値判定している場合はバージョンを上げた直後に再調整が要ります。
10. カメラと Windows の現実 — VideoCapture の起動時間を実測する
ここが、Windows で外観検査を作るときに最も時間を溶かす場所です。まず動くコードから。
import cv2
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) # Windows では第 2 引数を必ず指定する
if not cap.isOpened():
raise RuntimeError("カメラを開けません(番号を 1, 2... と変えて試す)")
try:
while True:
ret, frame = cap.read()
if not ret: # 抜線・切断でここに来る
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
t, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
cv2.imshow("binary", binary)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
cap.release()
cv2.destroyAllWindows()
10.1 CAP_DSHOW を付けるかどうかで起動が 8.8 倍変わった
cv2.VideoCapture(0) と書くと、OpenCV がバックエンド(カメラを扱う OS 側の仕組み)を自動選択します。Windows の選択肢は Microsoft Media Foundation(MSMF)と DirectShow(DSHOW)で、自動では MSMF が選ばれました。この差を検証機の UVC カメラ 1 台(既定 640×480)で計測した結果です。値は「VideoCapture を作ってから最初の read() が返るまで」を 3 回測った中央値。
| 指定 | 解像度の指定なし | 640×480 を指定 | 1280×720 を指定 |
|---|---|---|---|
cv2.CAP_DSHOW | 0.41 秒 | 0.79 秒 | 0.81 秒 |
cv2.CAP_MSMF | 2.77 秒 | 7.14 秒 | 7.15 秒 |
cv2.CAP_ANY(=第 2 引数を省略) | 2.67 秒(MSMF が選ばれた) | 7.08 秒 | 7.08 秒 |
同じ解像度どうしで比べると、1280×720 で 0.81 秒 対 7.15 秒=8.8 倍、640×480 でも 0.79 秒 対 7.14 秒=9.0 倍です。この差は 5.0.0.93 と 4.12.0.88 のどちらでも同じように出たので、バージョンではなくバックエンドの問題です。カメラ番号を 0〜3 まで総当たりする処理も、DSHOW なら 0.67 秒、自動選択では 3.68 秒でした。
同じ傾向は OpenCV の Issue #27917 でも報告されています(報告値は MSMF が既定 2.86 秒・幅と高さを指定して 5.71 秒、DirectShow が既定 1.35 秒・指定して 1.44 秒。別環境での他者の報告値で当サイトの実測とは分けて扱いますが、傾向は一致しています)。Windows で VideoCapture を書くときは、理由が無ければ cv2.CAP_DSHOW を指定してください。
ただし DSHOW にも弱点があります。実測では cap.get(cv2.CAP_PROP_FPS) が DSHOW では取得できず(後述の「非対応」の値が返る)、MSMF では 30.0 が返りました。フレームレートは取得するのではなく実測するのが確実です。DSHOW から 120 フレーム取得したところ 12.01 秒=10.0 fps、read() 1 回は中央値 96.3 ms でした(暗い室内で露光時間が延びた影響と考えられます)。タクトタイムは、この実測 fps を根拠に設計してください。
10.2 cap.get() の戻り値が OpenCV 5 で変わった
「このカメラはオートフォーカスを制御できるのか」を cap.get() で調べるコードは定番ですが、ここに 4.x → 5.x の変更が直撃します。公式の移行ガイドには、4.x では非対応のプロパティを問い合わせると 0 が返っていたが 5.0 では −1 になった、と書かれています。実際 cv2.CAP_PROP_UNKNOWN は 5.0.0.93 で -1、4.12.0.88 では定数自体が未定義でした。
ここからが実測で分かった点です。検証機で両バージョンの戻り値を全プロパティで比較したところ、実際に 0 から −1 に変わったのは DirectShow の CAP_PROP_FPS だけでした。オートフォーカス・フォーカス・色温度・ズーム・絞り・バッファサイズは、4.12.0.88 の時点で既に −1 を返していました。
| プロパティ(DSHOW で取得) | 4.12.0.88 | 5.0.0.93 |
|---|---|---|
CAP_PROP_FRAME_WIDTH / HEIGHT | 640.0 / 480.0 | 640.0 / 480.0 |
CAP_PROP_FPS | 0.0 | -1.0 |
CAP_PROP_AUTOFOCUS / FOCUS / ZOOM / IRIS | -1.0 | -1.0 |
CAP_PROP_TEMPERATURE(MSMF では 4600.0 が返る) | -1.0 | -1.0 |
つまり「4.x なら 0 が返る」と決め打ちした分岐は、4.x のうちから既に成り立っていません。バージョンとバックエンドの両方で挙動が変わる以上、書くべき判定はこうなります。
value = cap.get(cv2.CAP_PROP_FPS)
if value <= 0: # 4.x の 0 と 5.x の -1 の両方を「非対応」とみなす
print("このバックエンドでは取得できない値です")
set() のほうも、戻り値が True でも反映されているとは限りません。解像度を指定したら、最初のフレームの frame.shape で必ず確認してください。
10.3 カメラ番号と、つながらないときの切り分け
for i in range(4):
cap = cv2.VideoCapture(i, cv2.CAP_DSHOW) # 指定しないと 1 回あたり数秒待たされる
print(i, "->", "OK" if cap.isOpened() else "無し", cap.getBackendName() if cap.isOpened() else "")
cap.release()
USB カメラは抜き差しで割り当て番号が変わります。存在しない番号を開くと、実測では isOpened() が False、続けて read() を呼ぶと (False, None) が返りました(例外ではありません)。同時に VIDEOIO(DSHOW): backend is generally available but can't be used to capture by index という警告が出ます。
| 症状 | まず疑うこと |
|---|---|
isOpened() が False | カメラ番号違い/他アプリ(ビデオ会議ツール・カメラアプリ)が掴んでいる/Windows のカメラのプライバシー設定でアプリのアクセスが拒否されている |
| 起動に数秒かかる | バックエンドが MSMF になっている(10.1) |
read() が途中から False になる | USB の抜け・電力不足・ハブ経由。cap.release() してから VideoCapture を作り直す |
| 取得した設定値が全部 −1(または 0) | そのバックエンドが対応していないプロパティ(10.2) |
| 特定の Windows 更新後に落ちる | 公式フォーラムに、2026 年 2 月の Windows 11 更新(KB5077181)適用後に CAP_DSHOW がヒープ破損でクラッシュするという報告があります(単一の報告で OpenCV チームの見解は出ていません)。回避策として「USB カメラを全て外し、デバイスマネージャーで切断済みのイメージングデバイスを削除してから接続し直す」が共有されています |
カメラ選定の観点は 4 つです。グローバルシャッター(全画素を同時に露光する方式。安価なカメラに多いローリングシャッターは、動いている対象が斜めに歪みます)、解像度(最小欠陥が 3〜5 画素以上で写るか)、USB の帯域(複数台を 1 つのハブに束ねると落ちます)、フォーカス固定(オートフォーカスが動くと結果が日によって変わります)。
11. 撮像と照明が先、コードは後
この章の主張を、数字 1 つで説明します。3 章の合成画像(黒背景に明るい部品)を大津で二値化して findContours にかけると、輪郭は 1 個です。同じコードに、机に向けた実カメラの 640×480 フレームを流すと、輪郭は 1,436 個になりました。処理内容は 1 文字も変えていません。変わったのは撮像だけです。
「画像処理は最後の 2 割、撮影条件が 8 割」という言い方をよく聞きますが、その比率に一次情報はありません。数字の代わりに、公式ドキュメントに書かれた事実で言い換えます。OpenCV の輪郭抽出は「黒い背景から白い物体を見つける」処理であり、モルフォロジー処理も「前景は常に白に保て」と書かれています。撮像の段階で対象を背景から分離できていることが、後段すべての関数の前提条件です。ここが満たせていない画像でしきい値をいじって粘るのは、前提を壊したまま結果だけを合わせにいく作業になります。
最初に決めるのは照明の当て方です。光をどこから当てるかで拾える欠陥が変わります。下表は一般的な使い分けで、当サイトの比較実測ではありません。選定は照明機器メーカーの資料と現物でのテスト撮影で決めてください。
| 照明方式 | 当て方 | 得意な検査 |
|---|---|---|
| 拡散(リング・ドーム) | 広い面から柔らかく | 有無・個数・印字(A・B)。影とテカりが減る |
| 同軸落射 | レンズと同じ軸から | 鏡面・金属面の刻印や汚れ |
| ローアングル(暗視野) | 低い角度から斜めに | 線傷・打痕(C)。凹凸だけが光る |
| バックライト(透過) | 対象の裏側から | 外形寸法・穴径(A)。輪郭がシルエットになる |
- 背景の単純化: 黒・白・パターンのうち対象が最も浮き上がる背景を選ぶ。金属部品なら黒つや消しの背景板 1 枚で変わる
- 照明の固定: 検査用の LED リングライトを使い、部屋の照明を遮光する。工場の蛍光灯や安価な LED は電源周波数や PWM 調光で明滅する場合があり、露光時間が短いと明るさが撮影ごとにばらつくと言われます(当サイトでは未計測)。露出は必ず固定にする
- カメラと対象の固定: どちらも治具で固定する。9 章のとおり、ソフトの位置合わせは治具の代わりになりません
- 撮像条件の記録: 良い結果が出た日の照明位置・露光値・しきい値を残す。再現できない撮像は資産になりません
撮像を作り込むと、コード側は単純になります。逆に、コードが複雑になってきたら撮像をやり直す合図です。
12. ルールベースとディープラーニングの分岐
「ここまでの手法では判定できない」と分かってから、初めて学習系を検討します。判断の軸を表にします。
| 判断軸 | ルールベース(本記事) | ディープラーニング・異常検知 |
|---|---|---|
| 必要な準備 | 正常品の画像 1 枚、または 0 枚(8.2) | 正常品の画像を数百枚〜。不良の種類ごとにさらに必要 |
| 判定理由の説明 | できる(「面積が 680 px で規格の 500 px 超」) | 難しい。ヒートマップで示す程度 |
| 寸法を数値で出す | できる | 基本的にできない(別途ルールベースが要る) |
| 閉域網での導入 | 問題なし(pip で 1 パッケージ) | PyTorch と学習済み重みの持ち込みが必要でハードルが高い |
| しきい値の調整 | 数字を直す | 再学習が要ることがある |
| 向いている不良 | 形・位置・寸法・明暗で定義できるもの | 「見れば分かるが言葉にできない」もの |
画像の異常検知を試すなら Anomalib(Apache 2.0・Python 3.10 以上)が、正常品だけで学習する手法をまとめており実装が速いです。ただし PyTorch に依存するため、閉域網の PC に入れるのは本記事のコードとは別次元の手間になります。ここでは紹介にとどめ、コードは扱いません。
画像ではなく数値データ(寸法の測定値、電流値、温度などの表形式データ)の異常検知なら、学習系でも導入は軽く済みます。手法の選び方は scikit-learn での異常検知にまとめました。OpenCV 5 の移行ガイド自身も、分離した cv2.ml の代替として「scikit-learn はよく保守されている代替である」と名指ししています。
OpenCV の cv2.dnn で推論だけ回すこともできますが、5.0 では Darknet 形式と Caffe 形式の読み込みが削除されました(当サイトの実測でも readNetFromDarknet は 4.12.0.88 にはあり、5.0.0.93 には存在しませんでした)。YOLO 系のモデルを使う場合は ONNX 形式への変換が前提になります(YOLO 系は配布元によってライセンスが異なるので、業務で使う前に必ず確認してください)。
# 学習済みモデルを持っている場合の例(このままでは動きません)
net = cv2.dnn.readNet("model.onnx") # 5.0 では新エンジンを優先し、必要なら旧エンジンへ自動で戻る
blob = cv2.dnn.blobFromImage(img, scalefactor=1 / 255.0, size=(640, 640), swapRB=True)
net.setInput(blob)
output = net.forward()
13. 24 時間運用チェックリストとおわりに
試作が通ったあと、実際に流し続けるために決めておく項目です。ここを決めずに常駐させると、止まったときに何も分からなくなります。
| 項目 | やること | 頻度・きっかけ |
|---|---|---|
| しきい値の記録 | 大津が選んだしきい値(threshold の第 1 戻り値)を記録する | 毎回 |
| 処理時間の記録 | 1 枚あたりの処理時間をログに出し、延びていないか見る | 毎回+週次で確認 |
| NG 画像の保存 | NG 画像を日付フォルダに残し、後から再判定できるようにする | NG 発生時 |
| 判定結果の保存 | 結果を SQLite に書き、品質管理グラフとして集計する | 毎回/日次 |
| カメラの再接続 | read() が False なら release() して作り直す | 失敗検知時 |
| バージョンの固定 | ピン留めし、上げるときは同じ画像で結果を突き合わせる | 構築時・更新時 |
| 物理点検 | レンズの汚れ・埃、照明の劣化、治具のゆるみ、カメラの発熱 | 始業時・定期 |
| 基準画像の見直し | マスター差分を使うなら、ロット・材料が変わったら撮り直す | ロット切替時 |
常駐のさせ方は Windows サービス化、現場 PC への配布は exe 化、検査アプリの画面は Tkinter の監視アプリにまとめています。
外観検査の主要な手法は、OpenCV なら数十行の Python で書けます。本記事の範囲でも、寸法測定・個数判定・位置照合・傷検出まで一通り動きました。難しいのはコードではなく、「何を不良と呼ぶか」を決めることと、それが毎回同じように写る撮像を作ることです。この 2 つが決まっていれば実装は短時間で終わり、決まっていなければどんな高価な専用機を入れても判定は安定しません。逆に言えば、この試作は「専用機に何を要求すべきか」を固める材料そのものです。量産ラインでの検査保証・タクトタイム・ベンダーサポートが要るなら、その要件を持って専用機を選定するのが最短ルートになります。
⚠️ 実機適用時の注意: 本記事は外観検査の PoC(概念検証)を自宅 PC で完結させることを目的とし、量産ラインでの自動良否判定への直接組み込みは想定していません。実際に量産ラインの判定に組み込む場合は、以下を必ず実施してください: ①現場の実物 NG 品・正常品・境界品で閾値を再検証、②誤検知(False Positive)・見逃し(False Negative)の許容範囲を品質部門と合意、③OK/NG 判定は最終的に人間のレビュー・ダブルチェックを噛ませる、④照明・カメラ・治具の変動要因(フリッカ・環境光・埃)を含めた長時間試験、⑤トレーサビリティ(判定履歴の保存・逆引き)の設計。24 時間無人運用は、これらの前提が整ったうえでの選択肢です。本記事のコードによる不良流出・過剰廃棄・機会損失について筆者・GenbaPy は責任を負いません。