良くあるハイスループットなフェノタイピングとしてベルトコンベアを用いたものや専用のレールに取り付けられたカメラを用いたもの、ドローンを用いたものなどがあると思います。ただ、どれも多少の導入コストがかかるため、1回きりの調査には使いづらい感があります。
特に個人ではなかなか手が出しづらいです。
そこで、スマホとその辺にある台車をつかって手軽にデータを取れないか試してみました。
具体的には、以下の図に示すような装置を作って、装置を手で速度を一定に保ちながら動かして、対象の動画を撮影しました。
今回は対象として徳島県産と長崎県産のニンジンを使いました。それぞれのニンジンの産地がわかるように各サンプルの左端には1辺10cmで産地名をコードしたQRを配置しました。
イメージはこのような感じです。

実際に撮影された動画はこのようになりました。

動画の各フレームからQRとニンジンを検出する
動画は複数の連続する画像(フレーム)からなっているので各フレームからQRとニンジンを検出していきます。opencvを用いて動画から各フレームを読み込んで検出、結果をディスプレイに表示、ファイルに書き込みという一連の処理は、以下のループで実装しました。
video_path = "input.mp4" cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise IOError(f"Cannot open video file: {video_path}") # 動画の各種情報を取得する frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) fps = cap.get(cv2.CAP_PROP_FPS) # 検出結果の保存するための書き込み用オブジェクトも作っておく fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter('output.mp4', fourcc, fps, (int(frame_width), int(frame_height))) # 動画のフレームをループして1つずつ処理する while cap.isOpened(): # 動画から1フレーム読み込む success, frame = cap.read() pos_frames = cap.get(cv2.CAP_PROP_POS_FRAMES) if success: # frameに対してメイン処理 # 検出結果を描画したフレームを出力ファイルに書き込む out.write(frame) # 同じ情報をディスプレイ上にも表示する cv2.imshow("ニンジントラッキング", frame) # qキーが押下されたら終了する if cv2.waitKey(1) & 0xFF == ord("q"): break
QRの検出
QRについての検出はopencvのQRCodeDetectorArucoを用いました。
qr_detector = cv2.QRCodeDetectorAruco() qr_detected, decoded_info_list, corners_list, _ = qr_detector.detectAndDecodeMulti(frame)
ニンジンの検出
ニンジンに検出にはyolo26を用いました。Ultralyticsによって開発されているライブラリに含まれています。かなりしっかりとメンテナンスされているいんしょうで、ホビー用途では無料で使えるのでありがたいです。
ニンジンの検出は少し特殊で、1フレームに複数映る可能性があるので、各個体を識別しながら検出する必要があります。 そこで今回は各検出のトラッキングを行い、同一個体と予測されたものに識別可能なIDを振る処理を行いました。
UltralyticsのYOLOを用いる場合はすごく簡単でmodel.track()でそれが可能です。
model = YOLO("yolo26n-seg.pt") result = model.track(frame, conf=0.8, iou=0.8, persist=True)[0]
検出についてをまとめたコード全体
以下に全体的なコードを示します。各フレームで検出されたQRコードとニンジンの情報を格納し、最後にデータフレームにしています。また、検出状況はディスプレイに表示されるようにしていますが、後で見返しもできるように動画に記録もしています。
コードは以下を参考にしました。
今回はニンジンの根長と根径を検出時のバウンディングボックスの高さと幅で大まかに近似して測ることにしています。
from ultralytics import YOLO from collections import defaultdict import cv2 import numpy as np from ultralytics import YOLO from dataclasses import dataclass import pandas as pd @dataclass class Carrot: id: int x: float y: float w: float h: float pos_frames: float @dataclass class QRCode: decoded_info: str x: float y: float side_length: float pos_frames: float # YOLO26モデルをロードする(今回はニンジンをこのモデルを使って検出する) model = YOLO("yolo26n-seg.pt") # OpenCVのQR検出器を作成する qr_detector = cv2.QRCodeDetectorAruco() # 動画を開く video_path = "input.mp4" cap = cv2.VideoCapture(video_path) if not cap.isOpened(): raise IOError(f"Cannot open video file: {video_path}") # 動画の各種情報を取得する frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT) frame_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH) frame_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT) fps = cap.get(cv2.CAP_PROP_FPS) print(f"総フレーム数: {frame_count}, フレーム幅: {frame_width}, フレーム高さ: {frame_height}, FPS: {fps}") # 検出結果の保存するための書き込み用オブジェクトも作っておく fourcc = cv2.VideoWriter_fourcc(*'mp4v') out = cv2.VideoWriter('output.mp4', fourcc, fps, (int(frame_width), int(frame_height))) # 検出されたQRコードとニンジンの保存のためのリスト qr_codes = [] carrots = [] # 動画のフレームをループして1つずつ処理する while cap.isOpened(): # 動画から1フレーム読み込む success, frame = cap.read() pos_frames = cap.get(cv2.CAP_PROP_POS_FRAMES) if success: # YOLOを実行して対象をトラッキングする result = model.track(frame, conf=0.8, iou=0.8, persist=True)[0] # 得られたバウンディングボックスがあれば処理を開始 if result.boxes and result.boxes.is_track: boxes = result.boxes.xywh.cpu() track_ids = result.boxes.id.int().cpu().tolist() classes = result.boxes.cls.int().cpu().tolist() # 対象検出結果をフレームに描画する frame = result.plot() # 検出された物体を1つずつ処理する for box, track_id, class_id, in zip(boxes, track_ids, classes): if model.names[class_id] != 'carrot': # ニンジン以外はスキップ continue x, y, w, h = box # ニンジン検出結果を保存 carrots.append(Carrot( id=track_id, x=float(x), y=float(y), w=float(w), h=float(h), pos_frames=pos_frames )) # ニンジンの中心座標にマーカーを描画する cv2.drawMarker(frame, (int(x), int(y)), (255, 255, 0), 1, 50, 10) # QRコード検出を行う qr_detected, decoded_info_list, corners_list, _ = qr_detector.detectAndDecodeMulti(frame) if qr_detected: for decoded_info, corners in zip(decoded_info_list, corners_list): if decoded_info == "": # デコードされた結果が空文字列の場合はスキップ continue # QRの中心座標を計算して描画する(コーナー座標の平均) x, y = corners.mean(axis=0) cv2.drawMarker(frame, (int(x), int(y)), (255, 255, 0), 1, 50, 10) # QRの辺の長さを計算する side_length = np.mean(np.linalg.norm(corners - np.roll(corners, shift=1, axis=0), axis=1)) # QR検出結果を保存 qr_codes.append(QRCode( decoded_info=decoded_info, x=x, y=y, side_length=side_length, pos_frames=pos_frames )) # QRの検出結果を描画する corners = corners.astype(int) for i in range(len(corners)): cv2.line(frame, tuple(corners[i]), tuple(corners[(i + 1) % len(corners)]), (0, 255, 0), 10) if decoded_info: cv2.putText(frame, decoded_info, (corners[0, 0], corners[0, 1] - 10), cv2.FONT_HERSHEY_SIMPLEX, 3, (0, 255, 0), 10) # 検出結果を描画したフレームを出力ファイルに書き込む out.write(frame) # 同じ情報をディスプレイ上にも表示する cv2.imshow("ニンジントラッキング", frame) # qキーが押下されたら終了する if cv2.waitKey(1) & 0xFF == ord("q"): break else: # 動画が最後に到達したら終了する break # 動画キャプチャオブジェクトを解放して結果表示ウィンドウを閉じる cap.release() out.release() cv2.destroyAllWindows() # 結果をデータフレームに保存する df_carrots = pd.DataFrame(carrots) df_qr_codes = pd.DataFrame(qr_codes)
総フレーム数: 600.0, フレーム幅: 2160.0, フレーム高さ: 3840.0, FPS: 60.0
検出の様子
結果は以下のGIF動画に示すようになりました。

検出したデータから目的の形質を集計する
ニンジン検出結果は以下のようになり1000フレーム以上から結果が得られていることになました。YOLOのトラッキング機能によって各検出にはIDが振られており、同一IDイコール同一個体として識別可能になります。また、各検出の中心座標(x, y)、バウンディングボックスの幅と高さ(w, h)と検出されたフレーム位置(何番目のフレームか)(pos_frames)を今回は記録しています。
df_carrots
| id | x | y | w | h | pos_frames | |
|---|---|---|---|---|---|---|
| 0 | 1 | 1949.486328 | 1463.835205 | 421.027344 | 1487.324707 | 1.0 |
| 1 | 1 | 1949.034424 | 1463.601929 | 421.827026 | 1487.832764 | 2.0 |
| 2 | 1 | 1948.967529 | 1463.503540 | 421.823364 | 1488.549561 | 3.0 |
| 3 | 1 | 1948.907471 | 1462.683838 | 421.866211 | 1488.790039 | 4.0 |
| 4 | 1 | 1948.970947 | 1461.983032 | 421.774292 | 1488.717041 | 5.0 |
| ... | ... | ... | ... | ... | ... | ... |
| 1001 | 5 | 687.110168 | 2273.889160 | 365.511963 | 2044.912231 | 599.0 |
| 1002 | 6 | 1145.700928 | 1989.584961 | 387.035339 | 1371.399048 | 599.0 |
| 1003 | 4 | 199.110947 | 2048.687012 | 379.045929 | 1717.308960 | 600.0 |
| 1004 | 5 | 677.186340 | 2278.349609 | 364.560333 | 2052.345947 | 600.0 |
| 1005 | 6 | 1134.652466 | 1989.436890 | 385.959473 | 1371.355713 | 600.0 |
1006 rows × 6 columns
QRコードについても同様に見てみると160フレーム以上から検出ができていて、同様に中心座標(x, y)やフレーム位置(pos_frames)を記録しています。バウンディングボックス情報は記録していないのとID情報はありませんが、その代わりに、デコードされた情報、辺の長さ(px単位)を記録しています。
df_qr_codes
| decoded_info | x | y | side_length | pos_frames | |
|---|---|---|---|---|---|
| 0 | tokushima | 581.683594 | 1547.049805 | 859.927246 | 1.0 |
| 1 | tokushima | 581.452271 | 1547.780884 | 861.898560 | 2.0 |
| 2 | tokushima | 580.443970 | 1547.052734 | 860.664185 | 3.0 |
| 3 | tokushima | 580.452026 | 1547.276855 | 860.923462 | 4.0 |
| 4 | tokushima | 579.443665 | 1546.787354 | 860.894775 | 5.0 |
| ... | ... | ... | ... | ... | ... |
| 160 | nagasaki | 500.842163 | 1742.364380 | 862.768433 | 446.0 |
| 161 | nagasaki | 489.627319 | 1743.115723 | 862.798096 | 447.0 |
| 162 | nagasaki | 480.617737 | 1742.910400 | 865.022095 | 448.0 |
| 163 | nagasaki | 472.753601 | 1745.186157 | 864.993408 | 449.0 |
| 164 | nagasaki | 465.642639 | 1745.708862 | 864.330017 | 450.0 |
165 rows × 5 columns
各ニンジン個体がどのフレームで検出されたかをグラフにすると以下のようになります。
import matplotlib.pyplot as plt fig, ax = plt.subplots() ax.plot(df_carrots.pos_frames, [f"carrot:{i}" for i in df_carrots.id], 'C1o') plt.show()

ここで、フレームの端っこで検出されたニンジンは端っこが切れていたり、切れていなくても歪んでいたりする可能性があるので、それらを除きたいと思います。今回はニンジンの中心のx座標が動画フレームの中心10%に入るデータだけを切り出して解析に用いたいと思います。
中央10%に入るニンジンフレームからの情報だけ切り出す
ここで濃いオレンジでプロットした部分が残ったデータになります。
index = np.logical_and(df_carrots.x > frame_width * 0.45, df_carrots.x < frame_width * 0.55) df_carrots_center = df_carrots[index] fig, ax = plt.subplots() ax.plot(df_carrots.pos_frames, [f"carrot:{i}" for i in df_carrots.id], 'C1o', alpha=.01) ax.plot(df_carrots_center.pos_frames, [f"carrot:{i}" for i in df_carrots_center.id], 'C1o') plt.show()

各ニンジンの平均情報を算出する
動画フレームごとに検出などにノイズが入ることから、検出高さと幅などの平均を取ることとします。また、この時点で検出された平均フレームも取得します。
df_carrots_mean = df_carrots.groupby('id').mean().reset_index()
df_carrots_mean
| id | x | y | w | h | pos_frames | |
|---|---|---|---|---|---|---|
| 0 | 1 | 1206.867914 | 1494.401272 | 498.538615 | 1475.519982 | 121.299578 |
| 1 | 2 | 1182.876424 | 1599.698061 | 450.049639 | 1567.755488 | 188.234450 |
| 2 | 3 | 1051.950533 | 1568.823529 | 472.428185 | 1562.927545 | 264.211957 |
| 3 | 4 | 1144.410894 | 2046.994780 | 384.317635 | 1678.992444 | 516.987952 |
| 4 | 5 | 1352.268076 | 2288.102073 | 348.824204 | 2046.767100 | 538.696721 |
| 5 | 6 | 1615.287771 | 1993.774678 | 387.900986 | 1374.018268 | 554.670455 |
QRコードが出現したフレームと並べる
QRコードが検出されたフレームの情報を並べて表示してみると順番に検出できていることがわかります。各QRコードの平均フレームを算出し、それ以降に出現したニンジンにそのQRでコードされている情報(ここでは産地ですが、系統名などでも良いでしょう)を割り振りつつ、個体IDも産地ごとの動画での出現順に振りなおしたいと思います。
index = np.logical_and(df_qr_codes.x > frame_width * 0.2, df_qr_codes.x < frame_width * 0.8) fig, ax = plt.subplots() ax.plot(df_qr_codes[index].pos_frames, df_qr_codes[index].decoded_info, 'C0o') ax.plot(df_carrots_center.pos_frames, [f"carrot:{i}" for i in df_carrots_center.id], 'C1o') plt.show()

df_qr_codes_mean = df_qr_codes.groupby('decoded_info').mean().reset_index() df = pd.concat([df_carrots_mean, df_qr_codes_mean]) df.sort_values('pos_frames', inplace=True) df['type'] = np.where(df.decoded_info.isna(), 'carrot', 'QR') df[['decoded_info', 'side_length']] = df[['decoded_info', 'side_length']].ffill() df['individual_id'] = df.groupby('decoded_info').cumcount() df = df.reset_index(drop=True) df
| id | x | y | w | h | pos_frames | decoded_info | side_length | type | individual_id | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | NaN | 550.764160 | 1546.536377 | NaN | NaN | 26.500000 | tokushima | 861.869568 | QR | 0 |
| 1 | 1.0 | 1206.867914 | 1494.401272 | 498.538615 | 1475.519982 | 121.299578 | tokushima | 861.869568 | carrot | 1 |
| 2 | 2.0 | 1182.876424 | 1599.698061 | 450.049639 | 1567.755488 | 188.234450 | tokushima | 861.869568 | carrot | 2 |
| 3 | 3.0 | 1051.950533 | 1568.823529 | 472.428185 | 1562.927545 | 264.211957 | tokushima | 861.869568 | carrot | 3 |
| 4 | NaN | 1079.954102 | 1737.297241 | NaN | NaN | 394.000000 | nagasaki | 851.076233 | QR | 0 |
| 5 | 4.0 | 1144.410894 | 2046.994780 | 384.317635 | 1678.992444 | 516.987952 | nagasaki | 851.076233 | carrot | 1 |
| 6 | 5.0 | 1352.268076 | 2288.102073 | 348.824204 | 2046.767100 | 538.696721 | nagasaki | 851.076233 | carrot | 2 |
| 7 | 6.0 | 1615.287771 | 1993.774678 | 387.900986 | 1374.018268 | 554.670455 | nagasaki | 851.076233 | carrot | 3 |
検出されたQRコードの辺の長さをリファレンスとして根長と根径をcm単位で測定
今回用いたQRコードは1辺が10cmであることがわかっているのでその情報を元に、検出されたニンジンの根長と根径をそれぞれバウンディングボックスの高さと幅で近似して測定します。
df['diameter_cm']= df['w'] / df['side_length'] * 10 df['length_cm']= df['h'] / df['side_length'] * 10 df
| id | x | y | w | h | pos_frames | decoded_info | side_length | type | individual_id | diameter_cm | length_cm | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | NaN | 550.764160 | 1546.536377 | NaN | NaN | 26.500000 | tokushima | 861.869568 | QR | 0 | NaN | NaN |
| 1 | 1.0 | 1206.867914 | 1494.401272 | 498.538615 | 1475.519982 | 121.299578 | tokushima | 861.869568 | carrot | 1 | 5.784386 | 17.119992 |
| 2 | 2.0 | 1182.876424 | 1599.698061 | 450.049639 | 1567.755488 | 188.234450 | tokushima | 861.869568 | carrot | 2 | 5.221784 | 18.190171 |
| 3 | 3.0 | 1051.950533 | 1568.823529 | 472.428185 | 1562.927545 | 264.211957 | tokushima | 861.869568 | carrot | 3 | 5.481435 | 18.134154 |
| 4 | NaN | 1079.954102 | 1737.297241 | NaN | NaN | 394.000000 | nagasaki | 851.076233 | QR | 0 | NaN | NaN |
| 5 | 4.0 | 1144.410894 | 2046.994780 | 384.317635 | 1678.992444 | 516.987952 | nagasaki | 851.076233 | carrot | 1 | 4.515666 | 19.727874 |
| 6 | 5.0 | 1352.268076 | 2288.102073 | 348.824204 | 2046.767100 | 538.696721 | nagasaki | 851.076233 | carrot | 2 | 4.098625 | 24.049163 |
| 7 | 6.0 | 1615.287771 | 1993.774678 | 387.900986 | 1374.018268 | 554.670455 | nagasaki | 851.076233 | carrot | 3 | 4.557770 | 16.144479 |
手計測の値と比較する
最後に、手計測で取得された根長と根径を今回の画像解析での測定値で比較してみます。
df_manual = pd.DataFrame({
'production_area': ['tokushima'] * 3 + ['nagasaki'] * 3,
'individual_id': [1, 2, 3] * 2,
'length_by_manual': [15.2, 16.0, 15.9, 18.0, 22.5, 14.8],
'diameter_by_manual': [5.0, 4.4, 4.8, 3.6, 3.5, 3.6],
})
df_manual
| production_area | individual_id | length_by_manual | diameter_by_manual | |
|---|---|---|---|---|
| 0 | tokushima | 1 | 15.2 | 5.0 |
| 1 | tokushima | 2 | 16.0 | 4.4 |
| 2 | tokushima | 3 | 15.9 | 4.8 |
| 3 | nagasaki | 1 | 18.0 | 3.6 |
| 4 | nagasaki | 2 | 22.5 | 3.5 |
| 5 | nagasaki | 3 | 14.8 | 3.6 |
df = df.rename(columns={'decoded_info': 'production_area'})
df = df.set_index(['production_area', 'individual_id']).join(df_manual.set_index(['production_area', 'individual_id']))
plt.scatter(
x=df['length_by_manual'],
y=df['length_cm']
)
plt.axline(xy1=[df['length_by_manual'].mean()]*2, slope=1)
plt.axis('equal')
plt.show()

plt.scatter(
x=df['diameter_by_manual'],
y=df['diameter_cm']
)
plt.axline(xy1=[df['diameter_by_manual'].mean()]*2, slope=1)
plt.axis('equal')
plt.show()

df = df['length_cm'] - df['length_by_manual'])
production_area individual_id
tokushima 0 NaN
1 1.919992
2 2.190171
3 2.234154
nagasaki 0 NaN
1 1.727874
2 1.549163
3 1.344479
dtype: float64
df['diameter_cm'] - df['diameter_by_manual']
production_area individual_id
tokushima 0 NaN
1 0.784386
2 0.821784
3 0.681435
nagasaki 0 NaN
1 0.915666
2 0.598625
3 0.957770
dtype: float64
まとめ
- スマホを移動する形式で撮影した画像からQRコード検出、計測対象物体検出は可能
- QRをサイズリファレンスとして手計測値と相関するデータは得られそう
- ただし今回の設定・手法だと1~2cmほど大きく見積もってしまう
今回生じた誤差に関しては以前書いた記事にある内容も関係しているかもしれません。






































