いろんな匂いを識別できる匂いセンサを作る (Bosch BME688利用)


匂いをセンシングするデバイスは、匂いセンサや電子鼻(Electronic nose/Digital Nose)などとよばれます。

今回は Bosch の BME688 という空気質センサと機械学習を利用してさまざまな匂いを区別するセンサを作ってみましょう。

## 匂いを識別する仕組み

さまざまな匂いを電子的に検出する方法としては大きく以下の二種類があります。

- 水晶振動子型
  - 水晶振動子の表面に、特定の構造を持つ分子が吸着しやすい感応膜を塗り、分子が吸着すると水晶振動子の重さがわずかに変化することを周波数変化でとらえる。感応膜の種類を複数用意し、それぞれの膜での変化量を計測することで、匂いの識別を行う。
  - 参考: [水晶振動子ガスセンサを用いた匂いセンサの開発](https://www.jstage.jst.go.jp/article/jasj/51/1/51_KJ00001450629/_pdf)
- 半導体型
  - MOX(Metal OXide)センサと呼ばれる金属酸化物(SnO2など)を利用したセンサでは、加熱(100℃～400℃程度)した金属酸化物表面にガスが接触すると金属表面から電子を奪ったり与えたりする反応が起こり、それによって金属酸化物の電気抵抗値が変化することを利用する。安価。
  - 参考: [Learn about the possibilities and limitations of MOX sensors and the strength of 
Sensirion’s SGP4x products](https://sensirion.com/media/documents/0083CDF4/6294DFEA/Info_Note_MOX_sensor.pdf)

今回利用する BME688 は半導体型なのですが、センサ付近に設定可能なヒーターが搭載されており、センサ部の温度と持続時間を設定できるのが既存の半導体型のセンサとの大きな違いです。BME688は最大10個までシーケンスを設定することができます。また、これを自動的に繰り返すことができ、その切り替えのタイミングごとに電気抵抗値を取得することも可能です。
なお、類似した型番のセンサに BME680 というものがありますが、こちらにはヒーターの設定機能はありません。

![温度と持続時間を変化させて抵抗値を計測](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/1802ea94-3113-d9c0-a713-8f01986ea5a4.png)

上の図は、このセンサで設定できる温度と持続時間の一例を示しています。縦軸がセンサ部の温度、横軸が経過時間、点が電気抵抗値の計測タイミング(10か所)を表しています(図は[BME688 Datasheet](https://www.bosch-sensortec.com/media/boschsensortec/downloads/datasheets/bst-bme688-ds000.pdf)より引用)。センサ部は非常に小さく、熱容量も小さいため、20-30ms程度で設定した温度に達します。
上のグラフにおいて同じ温度で複数回連続して測定しているのは一見すると無駄のようにも思えますが、これは、匂い分子の質量や反応性の違いなどにより、センサ表面への分子の吸脱着の速度に差が生じることを利用しているようです。

この設定は以下のように自由に変更が可能です。全体のコードは[M5Atom で BME688 のガスセンサ・ヒーターを操作するサンプル](https://gist.github.com/ksasao/5505e0e59a97cde799cf0ed2d2009b2d)などを参照してください。

```c
  /* ヒーターの温度(℃)の１サイクル分の温度変化。 200-400℃程度を指定。配列の長さは最大10。*/
  uint16_t tempProf[10] = { 320, 100, 100, 100, 200, 200, 200, 320, 320,320 };
  /* ヒーターの温度を保持する時間の割合。数値×MEAS_DUR(ms)保持される。保持時間は1～4032ms。指定温度に達するまで20-30ms程度が必要。 */
  uint16_t mulProf[10] = { 5, 2, 10, 30, 5, 5, 5, 5, 5, 5 };
  /* 各測定(温度,湿度,気圧,抵抗値)の繰り返し間隔(MEAS_DUR)から測定にかかる正味時間を引いたものをsharedHeatrDurに設定 */
  uint16_t sharedHeatrDur = MEAS_DUR - (bme.getMeasDur(BME68X_PARALLEL_MODE) / 1000);
```

センサからは、電気抵抗値のほかに、温度、湿度、気圧も取得できるようになっています。測定対象となる匂いガスの分圧は周囲の気圧や温度の影響を多少受けます。また、水はセンサを含むあらゆるものに付着するので湿度を測定しておくことも重要です。

## センサの作製

センサを作成するために必要なものは下記のとおりです。はんだ付けは不要なのでソフトウェアの知識だけあればOKです。合計5000円くらいです。なお、M5Stack ATOM Lite にArduino IDE を利用して別途[ソフトウェア](https://gist.github.com/ksasao/5505e0e59a97cde799cf0ed2d2009b2d)を書き込む必要があります。

- [BME688搭載 4種空気質センサモジュール（ガス/温度/気圧/湿度）](https://www.switch-science.com/products/7383) ￥3,487
- [M5Stack ATOM Lite](https://www.switch-science.com/products/6262) ￥1,356
- [Qwiic - GROVEアダプタケーブル （100 mm）](https://www.switch-science.com/products/5327) ￥315
- USBケーブル Type A to Type C

組み立てるとこのようになります。結構小さいです。

![OMMF2022に出展した匂いセンサの例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/8c3bddb1-be41-2636-41ee-8079cdbb3300.png)


## 匂いのするものの準備

匂いのするものであればなんでもかまいません。試したものとしては、コーヒー豆、レモン、ライム、ニンニク、カレーパン、ドーナツ、固形石鹸、メロンソーダなどがありますが、いずれも十分にセンサに反応し、区別することができました。

なお、匂いにもよりますが、近づけただけではなかなか反応しないものもあるので、対象を下記のような袋に入れて計測しました。これは匂いの漏れにくいタイプですが、普通のビニール袋などでもかまいません。

- [ハウスホールドジャパン KZ30 においがもれないチャック袋 5枚](https://www.amazon.co.jp/dp/B07ZJ1FXQR) ￥118

## データの収集

センサから流れてくるデータをArduinoのシリアルプロッタで表示すると以下のようになります。コーヒーのにおいをかがせてみると波形の形状が変化していることが分かります。

![計測例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/1cd30806-8ffe-7898-3097-8d9b2fe2fa26.jpeg)

ただ、このままでは機械学習に使いにくいので、それぞれの計測値をベクトルとして扱えるよう、計測点ごとにIDを振っていきます。IDは上記の10ステップを設定すると、計測値ごとにセンサから取得できるので簡単です。

例えば上のコードをこのように変更します。

```c
char _text[256];
int skipCount = 2;
void loop(void)
{
	bme68xData data;
	uint8_t nFieldsLeft = 0;

	/* data being fetched for every 140ms */
	delay(MEAS_DUR);

	if (bme.fetchData())
	{
		do
		{
			nFieldsLeft = bme.getData(data);
			if (data.status == NEW_GAS_MEAS)
			{
                float current = log(data.gas_resistance);
                int index = data.gas_index;
                if(skipCount == 0){
                      sprintf(_text,"\"Index\":%d,\"Value\":%.3f,\"Temperature\":%.2f,\"Humidity\":%.2f,\"Pressure\":%.2f",
                      index,current,data.temperature,data.humidity,data.pressure);
                      Serial.println(_text);
                      String topicP = "Scent";
                      String dataP = "{\"Id\":\"Gas_04\","+ String(_text) + "}";
                }
                if(index == 9 && skipCount > 0){
                  skipCount--;
                  Serial.println("Waiting..");
                }  
			}
		} while (nFieldsLeft);
	}
}
```

## 機械学習向けデータセットの作製

今回は上記で収集したデータから下記のようなデータセットを作成しました。データは、```時刻,温度,湿度,気圧,データ0,...,データ9,ラベル```のようになっています。

```label.txt
なし
カレーパン
レモン
コーヒー
メロンソーダ
```

```data.csv
2022-12-03 07:47:09,32.93,22.87,102563.390625,11.904,16.355,16.038,15.613,13.010,12.887,12.772,11.649,11.805,11.872,0
2022-12-03 07:47:20,32.89,22.87,102564.1328125,11.904,16.362,16.043,15.617,13.005,12.887,12.773,11.646,11.807,11.867,0
2022-12-03 07:47:30,32.85,22.93,102565.953125,11.900,16.348,16.034,15.591,12.999,12.882,12.770,11.653,11.811,11.874,0
2022-12-04 10:00:50,32.89,39.93,102175.2734375,9.246,11.943,11.700,11.454,9.670,9.582,9.521,9.129,9.192,9.228,1
2022-12-04 10:01:01,32.89,39.84,102175.5234375,9.245,11.943,11.695,11.429,9.661,9.572,9.520,9.130,9.198,9.230,1
2022-12-04 10:01:12,32.88,39.85,102173,9.251,11.950,11.704,11.449,9.652,9.552,9.493,9.093,9.156,9.189,1
2022-12-04 10:01:22,32.91,40.36,102176.8515625,9.218,11.898,11.659,11.396,9.623,9.535,9.480,9.436,9.700,9.769,1
2022-12-04 10:04:35,30.78,25.89,102157.1171875,11.983,16.659,16.279,15.647,12.816,12.635,12.508,11.540,11.681,11.725,0
2022-12-04 10:04:46,30.77,26.61,102159.3515625,11.759,16.262,15.916,15.364,12.710,12.591,12.503,11.681,11.869,11.931,0
2022-12-04 10:10:06,30.74,53.14,102164.171875,10.510,14.051,13.154,12.067,10.332,10.276,10.221,10.252,10.405,10.464,2
2022-12-04 10:10:17,30.87,54.41,102164.7265625,10.490,14.009,13.096,12.027,10.318,10.261,10.212,10.242,10.397,10.456,2
2022-12-04 10:10:28,30.99,55.82,102168.53125,10.484,14.001,13.085,12.023,10.327,10.266,10.219,10.246,10.399,10.448,2
2022-12-04 10:39:19,31.07,34.21,102181.15625,11.045,15.125,14.371,13.332,11.033,10.988,10.944,10.736,10.890,10.947,3
2022-12-04 10:39:30,31.18,34.27,102186.1875,10.980,15.016,14.287,13.316,11.051,10.990,10.947,10.687,10.838,10.895,3
2022-12-04 10:39:41,31.26,34.26,102184.15625,10.926,14.938,14.205,13.298,11.055,10.994,10.945,10.652,10.800,10.853,3
2022-12-04 10:41:17,31.36,55.87,102194.5625,10.911,14.783,13.928,12.760,10.870,10.819,10.779,10.612,10.808,10.863,2
2022-12-04 10:41:28,31.40,56.43,102193.3515625,10.902,14.768,13.896,12.722,10.847,10.809,10.744,10.602,10.792,10.859,2
2022-12-04 13:23:18,31.16,62.79,101966.421875,11.072,15.037,14.128,12.883,10.989,10.933,10.884,10.742,10.952,11.023,2
2022-12-04 13:23:50,31.54,62.99,101964.4296875,11.039,14.982,14.046,12.772,10.935,10.882,10.831,10.721,10.927,10.999,2
2022-12-04 13:24:01,31.65,62.93,101969.2578125,11.037,14.967,14.031,12.747,10.918,10.870,10.822,10.713,10.922,10.996,2
2022-12-04 13:24:11,31.74,62.86,101965.09375,11.031,14.957,14.014,12.735,10.908,10.858,10.818,10.869,11.236,11.463,2
2022-12-04 13:24:22,31.58,56.15,101963.09375,11.615,16.030,15.339,14.102,11.868,11.827,11.793,11.472,11.738,11.853,2
2022-12-04 15:49:05,32.28,44.33,102085.3671875,11.587,15.961,15.622,15.088,12.497,12.378,12.287,11.398,11.528,11.570,4
2022-12-04 15:55:19,32.09,42.93,102086.59375,11.767,16.313,16.019,15.557,12.852,12.722,12.639,11.545,11.682,11.738,4
2022-12-04 15:55:30,32.23,43.38,102087.6484375,11.767,16.297,15.999,15.540,12.855,12.721,12.635,11.539,11.676,11.730,4
2022-12-16 13:06:43,30.51,28.83,101572.28125,11.968,16.589,16.038,14.871,11.943,11.841,11.761,11.596,11.795,11.875,3
2022-12-16 13:06:54,30.62,28.82,101570.8671875,11.928,16.511,15.980,14.818,11.926,11.826,11.752,11.569,11.755,11.845,3
2022-12-16 13:07:05,30.70,28.79,101569.15625,11.894,16.452,15.909,14.774,11.913,11.815,11.745,11.536,11.727,11.815,3
```

## 匂いの判定

ここまで準備ができたらあとは機械学習で対象を判別します。簡単なテーブルデータですので、今回は Random Forest を利用しました。

```predict.py
import pandas as pd
import numpy as np
import sklearn.datasets
import sklearn.ensemble
import sklearn.model_selection
import argparse

def predict(vector):
    # モデルの作成
    df = pd.read_csv("data.csv")

    data = np.array(df.iloc[:,[1,2,3, 4,5,6,7,8, 9,10,11,12,13]])
    target = np.array(df.iloc[:,14])

    # 学習
    train_x, test_x, train_y, test_y =sklearn.model_selection.train_test_split(data,target,test_size=0.2)
    rf = sklearn.ensemble.RandomForestClassifier()
    rf.fit(train_x, train_y)
    accuracy = rf.score(test_x, test_y)

    # 推論
    result = rf.predict([vector])[0]
    return result

def main():
    parser = argparse.ArgumentParser(description='匂い推論') 
    parser.add_argument('vector', help='数値ベクトル(13次元) をスペース区切りで入力してください', type=float, nargs='*')
    args = parser.parse_args()
    index = predict(args.vector)
    print(index)

if __name__ == '__main__':
    main()
```

コマンドの引数に```温度 湿度 気圧 データ0 ... データ9```を渡すと認識結果のIDが返ってきます。匂いが安定するまで1～2分かかりますが、おおむねうまく判定できるようです。画像はGUIなどをつけた例です。

![NT富山2022に出展した判定の様子。見学の方にご準備いただいたホットコーヒーを追加学習しました。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/43450/08c6a7a3-804c-6f61-dd1c-b12f9b73fc89.jpeg)


皆さんも是非試してみてください。