はじめに
老眼が進むと、小さいものが見えなくなります。
モジュール0.5のギヤの歯なんて、もはや肉眼では「なんかギザギザしてる」程度にしか見えません。ノギスで測ろうとしても歯が小さすぎて刃先が入りません。
困ったので、USB顕微鏡で写真を撮ってPythonに測ってもらうことにしました。老眼はPythonには関係ありません。
やることはシンプルです。
- 写真を撮る
- Pythonがギヤの形を認識する
- 歯の大きさを数値で出す
機械学習は使いません。OpenCVというライブラリの基本機能だけです。
AIに頼む場合の声のかけ方(早わかり)
コードを自分で書かなくても、ClaudeなどのAIに以下の順番で話しかければ同じことができます。
測定を始めるとき:
- 「ギヤの画像を解析して歯先円直径を計測してくれ」
- 「対象はモジュール0.5、8Tの平ギヤだ」
- 「スケールは画像の右端(または左端)に写っている」
- (画像をアップロードして)「これを解析してみ」
結果を受け取った後:
- 「歯ごとの歯先半径を出してくれ」
- 「平均と標準偏差を計算してくれ」
- 「理論値との差を出してくれ」
前回の結果と比較したいとき:
- 「前回の歯先円直径は5.115mmだった。今回と比較してくれ」
グラフが欲しいとき:
- 「画像で描いてくれ」
- 「前回のデータと重ねてグラフにしてくれ」
補正量を決めたいとき:
- 「理論値5.000mmに近づけるには何%スケールにすればいいか」
- 「HE補正なら何mmにすればいいか」
この順番で話しかければ、コードを1行も書かなくても今回と同じ計測が再現できます。老眼でもタイピングくらいはできます。
何を測りたいのか
今回測りたいのは歯先円直径です。
ギヤの設計値(理論値)は5.000mmですが、3Dプリントで作ると「なんとなく太い気がする」という問題がありました。どのくらい太いのかをちゃんと数字で確認したいわけです。
撮影
USB顕微鏡を金属製スタンドに固定して撮影します。解像度は1920×1080。
ポイントは2つです。
- 1mmスケールを必ず一緒に写す — これを忘れると「輪郭は取れたけど実際何mmか分からない」という悲しいことになります
- 黒い背景の上にギヤ単体を置く — 背景が明るいと歯底が影で背景と混ざり、歯の形が正確に取れません
最初は台座と一体成形のギヤをそのまま撮影していたのですが、台座も同じ色のPETGなので歯底の認識がうまくいきませんでした。台座なしで黒背景にしたところ、8本全部の歯が検出できるようになりました。
![台座なし・黒背景で撮影したギヤ]
この平ギヤは収縮を考慮して、設計直径は5.13mmで歯は幅を90%に細くしています。それで画像解析するとかなり良い数値がでています。水平拡張は0です。
台座なし・黒背景で撮影。右端に1mmスケールが写っている。
全体の流れ
コードは以下の5ステップで動きます。
Step 1: スケールの目盛りを読んで「1ピクセル = 何mm」を計算する
Step 2: オレンジ色の部分を認識してギヤを切り出す
Step 3: ギヤの輪郭(外形の線)を抽出する
Step 4: 中心から全方向に半径を計測する
Step 5: 歯先のピークを検出して直径を計算する
Step 1:キャリブレーション(1ピクセルは何mm?)
写真の中のスケール目盛りを自動で読んで、換算係数を計算します。
import cv2
import numpy as np
img = cv2.imread('gear.jpg') # 写真を読み込む
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # グレースケールに変換
h, w = gray.shape
# 写真の右端20%をスケール領域として切り出す
scale_region = gray[:, int(w * 0.80):]
# 各行の平均の明るさを計算する
row_mean = np.mean(scale_region, axis=1)
# 暗い行(黒い目盛り線)を探す
threshold = np.percentile(row_mean, 20)
dark = row_mean < threshold
# 暗い行のかたまりをグループ化して中心位置を求める
groups = []
in_group = False
for i in range(len(dark)):
if dark[i] and not in_group:
in_group = True
start = i
elif not dark[i] and in_group:
in_group = False
if i - start > 2:
groups.append((start + i) // 2)
# 目盛り間の間隔(ピクセル数)を計算する
intervals = np.diff(groups)
# 大きい間隔が1mmの目盛り間隔
px_per_mm = np.median(intervals[intervals > np.median(intervals) * 0.8])
print(f'1mm = {px_per_mm:.1f}ピクセル')
この画像では 1mm = 124ピクセル でした。
Step 2:色でギヤを認識する
オレンジ色のPETGをHSVという色の表現方法でマスクします。
# BGRからHSVに変換する(色の認識がしやすくなる)
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
# オレンジ色の範囲を指定してマスクを作る
# H(色相):8〜22、S(鮮やかさ):80以上、V(明るさ):100以上
mask = cv2.inRange(hsv, (8, 80, 100), (22, 255, 255))
# 小さな穴を埋める(歯底の影で途切れた部分を補完)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((20,20), np.uint8))
# 小さなノイズを除去する
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((15,15), np.uint8))
マスクを確認すると、ギヤ部分が白、背景が黒になっています。白い部分がPythonがギヤと認識した領域です。
Step 3:輪郭を抽出してギヤ中心を求める
マスクからギヤの外形の線(輪郭)を取り出し、中心座標を計算します。
# 輪郭を検出する
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
# 面積が一番大きい輪郭 = ギヤの外形
gear_contour = sorted(contours, key=cv2.contourArea, reverse=True)[0]
# 輪郭の重心 = ギヤの中心
M = cv2.moments(gear_contour)
cx = int(M['m10'] / M['m00'])
cy = int(M['m01'] / M['m00'])
print(f'ギヤ中心: ({cx}, {cy})')
cv2.moments()は輪郭の形から重心を計算します。老眼で目測する必要はありません。
Step 4:中心から全方向に半径を計測する
ギヤ中心から3600方向(0.1度刻み)にスキャンして、各方向でギヤの端までの距離(半径)を記録します。
from scipy import ndimage
n_angles = 3600
angles_rad = np.linspace(0, 2*np.pi, n_angles, endpoint=False)
radii = np.zeros(n_angles)
for i, a in enumerate(angles_rad):
cos_a, sin_a = np.cos(a), np.sin(a)
last_r = 0
for r in range(5, 400):
# 中心から半径rの位置のピクセル座標
px_x = int(round(cx + r * cos_a))
px_y = int(round(cy + r * sin_a))
if px_x < 0 or px_x >= w or px_y < 0 or px_y >= h:
break
# そのピクセルがギヤ(白)なら記録する
if mask[px_y, px_x] > 0:
last_r = r
radii[i] = last_r
# 欠損部分を補間してスムージングする
nz = radii > 0
idx = np.arange(n_angles)
radii[~nz] = np.interp(idx[~nz], idx[nz], radii[nz])
r_smooth = ndimage.uniform_filter1d(radii, size=18)
これでギヤを360度スキャンした半径プロファイルが得られます。グラフにすると歯先のところで山になり、歯底のところで谷になる波形が見えます。
Step 5:歯先を自動で見つけて直径を計算する
波形の山(歯先)を自動検出して歯先円直径を計算します。
from scipy.signal import find_peaks
r_std = np.std(r_smooth)
peaks, _ = find_peaks(
r_smooth,
distance=n_angles // 10, # 歯と歯の最小間隔
prominence=r_std * 0.4 # 山の最小高さ
)
tip_radii = r_smooth[peaks]
tip_mean = np.mean(tip_radii)
# ピクセルをmmに変換
tip_diameter = 2 * tip_mean / px_per_mm
print(f'検出した歯の数: {len(peaks)} 本')
print(f'歯先円直径: {tip_diameter:.3f} mm')
print(f'理論値との差: {tip_diameter - 5.000:+.3f} mm')
print()
print('歯ごとの歯先半径:')
for i, r in enumerate(tip_radii):
print(f' 歯{i+1}: {r/px_per_mm:.3f} mm')
print(f'ばらつき(std): {np.std(tip_radii)/px_per_mm:.4f} mm')
結果
1mm = 122.0ピクセル
ギヤ中心: (493, 339)
検出した歯の数: 8 本
歯先円直径: 5.075 mm
理論値との差: +0.075 mm
歯ごとの歯先半径:
歯1: 2.554 mm
歯2: 2.559 mm
歯3: 2.521 mm
歯4: 2.526 mm
歯5: 2.536 mm
歯6: 2.550 mm
歯7: 2.533 mm
歯8: 2.523 mm
ばらつき(std): 0.0136 mm
8本全部の歯が検出できました。黒背景の効果は絶大です。
結果グラフ
4枚のグラフが自動で生成されます。
左上(写真オーバーレイ)
シアンの線が検出した輪郭、オレンジの円が実測歯先円、黄色が理論値5.000mmです。黄色より少し外側にオレンジがあれば「やっぱり太かった」ということです。
右上(半径プロファイル)
全周をスキャンした半径の変化です。山が歯先、谷が歯底です。山の高さが歯ごとに微妙に違うことが分かります。これが3Dプリントの個性です。
左下(極座標プロファイル)
ギヤの形をそのまま丸く可視化したものです。理論値(黄色の破線)と実測(青い線)を重ねると、どの方向でどれくらい太いか一目で分かります。
右下(歯ごとのばらつき)
歯ごとの歯先半径の棒グラフです。全部同じ高さにはなりません。これが3Dプリントの造形特性で、実は音響特性にも影響しています(別記事参照)。
ハマりどころ
台座がギヤと同じ色
強度のためにギヤと台座を同じPETGで一体成形しています。色が同じなので歯底と台座の境界が認識できず、8本全部の歯先が取れないことがあります。台座なしで黒い背景の上で撮影すれば解決します。
毎回倍率が変わる
スタンドの高さを少し変えると倍率が変わり、1mm=何ピクセルかが変わります。毎回スケールを写し込んでキャリブレーションし直すのが正しいやり方です。「前回と同じ倍率のはず」は信用しないことにしました。
歯底が影になる
顕微鏡の照明が真上からだと歯と歯の間の谷(歯底)に影ができて色が変わります。歯先の計測は安定していますが歯底は正確に取れないことがあります。
まとめ
- USB顕微鏡で撮影→Pythonで解析という流れで、ギヤの歯先円直径を計測できました
- 機械学習は使いません。OpenCV・NumPy・SciPyの基本機能だけです
- 台座なし・黒背景で撮影することで8本全部の歯が検出できました
- モジュール0.5、8TのPETGギヤで歯先円直径が理論値より +0.075mm 太いことが分かりました
- 歯ごとのばらつき(std = 0.0136mm)も定量化できます
- コードを書かなくてもAIに頼めば同じことができます
老眼でも測れました。肉眼で見えないものはPythonに測ってもらいましょう。
環境
| 項目 | 内容 |
|---|---|
| 顕微鏡 | USB顕微鏡(1920×1080) |
| Python | 3.12(Anaconda) |
| ライブラリ | OpenCV・NumPy・SciPy・Matplotlib |
| 対象ギヤ | モジュール0.5、8T、PETG |
