1. はじめに
我が家には様々な招かれざる客が訪れます。
・敷地に入ってくる猫
・敷地に入れて犬のフンをさせるマナーの悪い飼い主
・カラスに荒らされるごみ
・軒下に泊まってベランダをフンだらけにするスズメ
というわけで、動物追い払いシステムを作りたかったのですが、全てをやるのはちょっと話が大きくなりすぎるので、一番困っている「敷地に入れて犬のフンをさせるマナーの悪い飼い主」を対策するシステムの開発をコンテストきっかけで試みました。
2. 動作原理
考えついたのは、カメラで敷地に侵入する犬を検知し、犬笛の周波数の音でびっくりさせて追い出すシステムとなります。
2-1. 構成図
基本的に下記の図の通り SPRESENSEボード本体に 拡張基板と カメラを付け LCD とアンプスピーカを取り付けた構成となっています。
音声ラインは内部のアンプだけでは音量が不足したので、外付けにさらにアンプを通してスピーカーを付けました。(これでもちょっと不足していました。びっくりさせるにはもう少し高利得アンプとスピーカーにした方がよさそうです。)
液晶は当初はデバッグ用でしたが、検知領域を調整するのに必要となったため常時接続するものとしました。
2-2. 処理の流れ
処理の詳細は 3-2 のソフトウェアの部分の "3-2-1. 処理方式" で説明しますが、ここでは大まかな処理の流れを示します。
(1). 画像撮影
SPRESENSE カメラで写真を撮ります
(2). 画像表示
撮った画像をLCD画面に表示します
(3). 動体検出
前回撮った画像と今撮った画像の差分から動くものがあるかを検出します。
(4). 画像認識・判定
動体検出された場合に、画像を処理してその中に犬の写真があるかを判定します。
★この部分が今回断念しました。
(5). 警告
犬が検出されたら、超音波(犬笛の周波数)で警告音を出します。
3. 構成
3-1. ハードウェア
3-1-1. 使用部品
名称 | 品名/品番 | スペック/説明 | 備考 |
---|---|---|---|
メイン基板 | SPRESENSE本体ボード | コンテストで提供いただいたもの | |
拡張基板 | SPRESENSE拡張用ボード | コンテストで提供いただいたもの | |
アンプ基板 | AE-TPA2006 | D級オーディオアンプ | elchikaさんのハードウェア作品投稿キャンペーン参加賞でいただいたもの |
モバイルバッテリ | cheero Canvas | 3200mAh | 家にあったもの |
スピーカー | 不明 | 8Ω1w | 余っていたジャンク品 |
3-1-2. 接続
以下の画像のように接続しました。
3線以上端子の接続がある、LCDディスプレイ周辺とアンプ周辺の2パターンの接続について、以下の表に補足します。
3-1-1-1. LCDディスプレイ周辺
名称 | SPRESENSE + 拡張基板 | LCDディスプレイ | 備考 |
---|---|---|---|
電源+ | JP2-3: AREF | J2-1: VCC | |
Backlight | JP2-3: AREF | J2-8: LED | バックライト電源 |
電源- | JP2-4: GND | J2-2: GND | |
SCK | JP2-5: SPI_SCK | J2-7: SCK | |
MISO | JP2-6: SPI_MISO | J2-9: SDO | |
MOSI | JP2-7: SPI_MOSI | J2-6: SDI | |
ChipSelect | JP2-8: D10 SPI_CS | J2-3: CS | |
DataControl | JP2-9: D09 | J2-5: D/C | |
Reset | JP2-10: D08 | J2-4: RESET |
※ 基本的に、下記リンクのチュートリアル通りに配線しました。
Spresense Arduino チュートリアル - 3.2. Camera プレビュー画像を LCD に表示する
3-1-1-2. アンプ周辺
名称 | バッテリ | SPRESENSE+拡張基板 | アンプ | スピーカー | 備考 |
---|---|---|---|---|---|
シャットダウン入力 | 1: /SD | 接続無 | |||
入力+ | JP9-2: R- | 2: IN+ | |||
入力- | JP9-1: R+ | 3: IN- | |||
電源- | JP9-2: R- | 4: GND | |||
電源+ | USB-A-1: VBUS | CN1-1: + | 5: +V | 直付け | |
電源- | USB-A-4: GND | CN1-2: + | 6: GND | 直付け | |
出力+ | 7: OUT+ | + | EHコネクタ(1)経由 | ||
出力- | 8: OUT- | - | EHコネクタ(2)経由 |
他アンプ基板の JP③ をはんだ付けショートしました。
※ アンプのデータシートから推奨シングルエンド接続図を参考としました。
3-2. ソフトウェア
3-2-1. 処理方式
3-2-1-1. 画像撮影
この処理はほぼチュートリアル通り、写真が更新されたら呼ばれるコールバックの中で、ピクセルフォーマットを RGB565 にしてメモリ内に保持する処理します。
3-2-1-2. 画像表示
既に RGB565 に変換しているので、それをライブラリに渡して LCD画面に描画しています。
3-2-1-3. 動体検出
検出方法ですが、グレイスケール化した画像にて、検知可能領域内で輝度差分が特定量を超える画素が特定点数あるかどうかで判断しています。
と、文章で表現してもわかりにくいので、まずは以下動画を見ていただきたく思います。
[検知可能領域内で前回との輝度差分が特定量を超える画素の様子をデバッグした動画]
検知可能領域内とは画面内で赤い枠で囲まれた領域の内部のことです。
動画内でピンク色の画素が、輝度差分が特定量を超える画素です。
そして、この画素数の割合が特定量を超えたら検出とします。
ではそれぞれの処理を説明していきます。
3-2-1-3-1. グレイスケール化処理
動体検出を行うために全画素について前回値との差分をとります。全画素について差分をとってそれを保持しておく必要がある為どうしても情報が多くなってしまいます。
そこで動体検出ではグレイスケールの値を使用しました。(当初はこの後のAI系の "画像認識・判定" の処理でも判定する画像はグレイスケールを使うつもりでしたので都合良かったためです。)
グレイスケールへの変換は RGB565 の三原色のそれぞれの重みづけを 255 に拡張してから平均化するという方法で計算しました。画面表示のために RGB565 にいったん変換されてしまっていて、更にもう一度グレイスケールに変換する処理を行うと挙動が怪しかったので自力で処理することにしました。
3-2-1-3-2. 検知可能領域内
撮影されたカメラ画像には自宅敷地内だけではなく道路や近所の領域も当然映ってしまいます。
当然散歩の際に前の道を通った犬やお向かいの犬も検出してしまうことになって都合が悪いです。
そこで自宅敷地内への侵入と認識するためにはその領域を定義してやる必要があります。
本システムでは、あらかじめプログラムに領域を書き込んで置き、設置時にその枠に画像を合わせこんで調整する仕様としました。
またまた文章ではわかりにくいので、調整している様子の動画をご覧ください。
[領域調整の様子動画]
赤い枠を自宅敷地の境界線に合わせるように調整する様子が確認できます。
3-2-1-3-3. 輝度差分
輝度差分は、前回のグレイスケールの輝度と今回のグレイスケールの輝度の差分の絶対値です。
「輝度差分が特定量を超える画素」の輝度差分の特定量とは、後出のソースファイルの中で "MOTION_DETECT_THRESHOLD" として 輝度差が 50 /255 で定義されているものです。この値をえるかどうかで判断します。
3-2-1-3-4. 検出点数
「超える画素が特定点数あるか」の特定点数とは、後出のソースファイルの中で "PIXEL_COUNT_THREAHOLD" として 2.0% で定義されているものです。
検知可能領域内に含まれる画素数との割合 が 2.0% を超えているかどうかという意味となります。
3-2-1-4. 画像認識・判定
動体検出された場合に、そこに犬が含まれているかどうかを判別させるための処理です。
犬じゃなかった場合に余計な音を出してしまわないようにする目的の処理です。
ここで SPRESENSE の AI 機能を使って、学習データをもとに動物の判定を行う予定でした。
しかしこの部分の実装は今回断念しました。どうもカメラの処理と音声再生だけで大分メモリを使うらしく、同時に動かすことがどうしても出来ませんでした。
色々と回避策がないか頑張りましたが、ライブラリ内部の事なのでよくわからずに時間だけが無駄に過ぎてしまったという状況です。
とはいえ、人間(飼い主)に音は聞こえないはずですので、できなくてもあまり支障はなさそうです。懸念点は別の動物がどう反応するかくらいです。ですので今回はこの処理なしで行うこととしました。
3-2-1-5. 警告
犬が検出されたら、超音波(犬笛の周波数)で警告音を出します。
犬笛の周波数は30KHz 程とのことです。
当初は BEEP 音再生で十分かとおもっていましたが、BEEP音再生機能はどうも人間の可聴領域しかできないらしく、 30KHz といった高周波を与えてやるとエラー発生で出来ませんでした。
ここではハイレゾ音声の 30KHz のサイン波が収録された WAV ファイルを作成して、MicroSD に保存してやり、それを再生しています。
WAV ファイルは、WeveGene というツールを使って作りました。詳しくは "3-2-3. 事前準備 - 3-2-3-1. 音声ファイル" の所に書いています。
処理は ArduinoIDE から、[スケッチ例] - [ SPRESENSE関連のスケッチ例] - [audio] - [application] - [player_wav] を参考にしました。
シリアルログを見ていると警告がいっぱい出ていましたが、そのあたりはよく原因がわかりませんでした。未解決です。
3-2-2. プログラム
ArduinoIDE を使用します。Arduino を立ち上げて以下を準備していきます。
3-2-2-1. 必要ライブラリ
以下の2つのライブラリが必要です。
- Adafruit-GFX-Library
- Adafruit_ILI9341
あらかじめ下記チュートリアル記事を参考にインストールしておきます。
Spresense Arduino チュートリアル - 3.2.2.2. Adafruit ILI9341
但し、私の環境は ESP32 関連や他マイコン等同じライブラリを使っていて競合したので、以下の記事を参考に細工して使いました。
SPRSENSEでILI9341 SPI液晶をライブラリを競合させないで動かす方法
3-2-2-2. DSPファイル
以下のサイトを参考に "DSP" ファイルをMicroSD に格納しました。
Spresense Arduino チュートリアル - 1.1. DSP ファイルのインストール
3-2-2-3. ソースファイル
Arduino IDE にて新規ファイルを作成し、以下をコピペします。
一部テーブルの変更が必要です。"3-2-3-2. 検知領域定義テーブル作成" と "3-2-3-2-1. 検知領域範囲描画用テーブル" を参考に環境に応じて変更します。
DogWatcherWithSPRESENSE.ino
// -----=====<<<<<[[[[[ Include ]]]]]>>>>>=====-----
#include <Audio.h>
#include <Camera.h>
#include <SDHCI.h>
#include "Adafruit_ILI9341.h"
// -----=====<<<<<[[[[[ Define ]]]]]>>>>>=====-----
// 汎用マクロ
#define ARRAY_SIZEOF(arr) (sizeof(arr)/sizeof(arr[0]))
//#define USE_BEEP_TO_PLAY 1
// シリアルログ出力用
#define debugPrintf(...) Serial.printf(__VA_ARGS__)
#define debugPrint(str) Serial.print(str)
#define debugPrintln(str) Serial.println(str)
// PIN定義
#define PIN_TFT_DC 9
#define PIN_TFT_CS 10
#define PIN_DIPSW_IMAGE_IN 7
#define PIN_DIPSW_IMAGE_OUT 6
#define PIN_DIPSW_DEBUG_IN 0
#define PIN_DIPSW_DEBUG_OUT 1
#define PIN_DIPSW_HEARABLE_IN 2
#define PIN_DIPSW_HEARABLE_OUT 3
#define PIN_LED_DETECT_INDICATOR LED0
#define PIN_LED_SAVE_INDICATOR LED1
#define PIN_LED_SOUND_INDICATOR LED2
// 画像定義
#define IMAGE_WIDTH 320
#define IMAGE_HEIGHT 240
// 音声定義
#define DOG_WHISTLE_FREQ 12000 // 犬笛の周波数(20KHz)警告用
#define HUMAN_HEARABLE_FREQ 3520 // 人間の可聴域周波数 (3.5KHz) デバッグ用
#define PLAY_SOUND_BEEP_VOLUME -20
// ディレクトリ定義
#define PHOTO_DIR "/Photo" // 写真格納用
#define AUDIO_DIR "/Audio" // 音声格納用
#define DOG_WHISTLE_AUDIO_FILENAME "/sin3520_30k.wav"
#define HUMAN_HEARABLE_AUDIO_FILENAME "/sin3520.wav"
// 判定パラメータ
#define MOTION_DETECT_THRESHOLD 50 // ピクセル単位の変化を検知する輝度差の境界値 1/255 単位(MAX:255)
#define PIXEL_COUNT_THREAHOLD 20 // 0.1%単位
// 変数置き換え
#define the_camera theCamera
// -----=====<<<<<[[[[[ Types ]]]]]>>>>>=====-----
// [構造体]
// RGB565用イメージバッファ
typedef struct {
uint16_t pixel_data[IMAGE_HEIGHT][IMAGE_WIDTH];
} rgb565_pixel_data_t;
// グレイスケール用イメージバッファ
typedef struct {
uint8_t pixel_data[IMAGE_HEIGHT][IMAGE_WIDTH];
} grayscale_pixel_data_t;
// -----=====<<<<<[[[[[ Valiable ]]]]]>>>>>=====-----
// [ライブラリ関連]
Adafruit_ILI9341 display = Adafruit_ILI9341(PIN_TFT_CS, PIN_TFT_DC);
AudioClass *the_audio;
SDClass the_sd;
// [グローバル変数]
int pixels_count_in_area = 0; // 領域内画素数
int detect_pixel_count; // 検知と判定する画素数
bool motion_detected = false; // 検知フラグ
int save_image_file_num = 1; // 次に保存する画像の番号
// 画像バッファ
rgb565_pixel_data_t rgb_image; // RGBデータを保持するためのバッファ(前回値保存用)
grayscale_pixel_data_t grayscale_image; // グレイスケールデータを保存するためのバッファ(ビットマップファイル保存用)
// 音声関連
File audio_file; // WAVファイルオブジェクト
WavContainerFormatParser wav_parser;
uint32_t wav_data_offset; // WAVファイル内の音声データ位置オフセット
uint32_t wav_remain_data_size; // 再生中のWAVファイルの残りバイト数
String audio_file_name; // WAVファイル名
const int32_t sc_buffer_size = 6144; // 音声バッファのサイズ
const uint32_t sc_prestore_frames = 10; // 先読みする音声バッファ数
const uint32_t sc_store_frames = 10; // 一度に読み込む音声バッファ数
bool audio_stopped_by_err = false; // 音声再生でのエラー発生フラグ
uint8_t audio_buffer[sc_buffer_size]; // 音声バッファ
// DIPスイッチ関連
bool is_image_save_mode = false; // 画像保存機能有効フラグ
bool is_hearable_sound = false; // 可聴音再生機能有効フラグ
bool is_debug_mode = false; // デバッグモード有効フラグ
// [テーブル]
// エリア範囲描画用テーブル
const struct {
int x, y;
} area_pixel_table[] = {
{0, 120},
{IMAGE_WIDTH-1, 105},
{IMAGE_WIDTH-1, IMAGE_HEIGHT-1},
{0, IMAGE_HEIGHT-1},
{0, 120}
};
// エリア定義テーブル (各x座標に置ける、y座標の最低値と最大値)
const struct {
int min_val, max_val;
} area_range_table[] = {
{ 120, 239 }, // x = 0
{ 119, 239 }, // x = 1
{ 119, 239 }, // x = 2
{ 119, 239 }, // x = 3
{ 119, 239 }, // x = 4
{ 119, 239 }, // x = 5
{ 119, 239 }, // x = 6
{ 119, 239 }, // x = 7
{ 119, 239 }, // x = 8
{ 119, 239 }, // x = 9
{ 119, 239 }, // x = 10
{ 119, 239 }, // x = 11
{ 119, 239 }, // x = 12
{ 119, 239 }, // x = 13
{ 119, 239 }, // x = 14
{ 119, 239 }, // x = 15
{ 119, 239 }, // x = 16
{ 119, 239 }, // x = 17
{ 119, 239 }, // x = 18
{ 119, 239 }, // x = 19
{ 119, 239 }, // x = 20
{ 119, 239 }, // x = 21
{ 118, 239 }, // x = 22
{ 118, 239 }, // x = 23
{ 118, 239 }, // x = 24
{ 118, 239 }, // x = 25
{ 118, 239 }, // x = 26
{ 118, 239 }, // x = 27
{ 118, 239 }, // x = 28
{ 118, 239 }, // x = 29
{ 118, 239 }, // x = 30
{ 118, 239 }, // x = 31
{ 118, 239 }, // x = 32
{ 118, 239 }, // x = 33
{ 118, 239 }, // x = 34
{ 118, 239 }, // x = 35
{ 118, 239 }, // x = 36
{ 118, 239 }, // x = 37
{ 118, 239 }, // x = 38
{ 118, 239 }, // x = 39
{ 118, 239 }, // x = 40
{ 118, 239 }, // x = 41
{ 118, 239 }, // x = 42
{ 117, 239 }, // x = 43
{ 117, 239 }, // x = 44
{ 117, 239 }, // x = 45
{ 117, 239 }, // x = 46
{ 117, 239 }, // x = 47
{ 117, 239 }, // x = 48
{ 117, 239 }, // x = 49
{ 117, 239 }, // x = 50
{ 117, 239 }, // x = 51
{ 117, 239 }, // x = 52
{ 117, 239 }, // x = 53
{ 117, 239 }, // x = 54
{ 117, 239 }, // x = 55
{ 117, 239 }, // x = 56
{ 117, 239 }, // x = 57
{ 117, 239 }, // x = 58
{ 117, 239 }, // x = 59
{ 117, 239 }, // x = 60
{ 117, 239 }, // x = 61
{ 117, 239 }, // x = 62
{ 117, 239 }, // x = 63
{ 116, 239 }, // x = 64
{ 116, 239 }, // x = 65
{ 116, 239 }, // x = 66
{ 116, 239 }, // x = 67
{ 116, 239 }, // x = 68
{ 116, 239 }, // x = 69
{ 116, 239 }, // x = 70
{ 116, 239 }, // x = 71
{ 116, 239 }, // x = 72
{ 116, 239 }, // x = 73
{ 116, 239 }, // x = 74
{ 116, 239 }, // x = 75
{ 116, 239 }, // x = 76
{ 116, 239 }, // x = 77
{ 116, 239 }, // x = 78
{ 116, 239 }, // x = 79
{ 116, 239 }, // x = 80
{ 116, 239 }, // x = 81
{ 116, 239 }, // x = 82
{ 116, 239 }, // x = 83
{ 116, 239 }, // x = 84
{ 116, 239 }, // x = 85
{ 115, 239 }, // x = 86
{ 115, 239 }, // x = 87
{ 115, 239 }, // x = 88
{ 115, 239 }, // x = 89
{ 115, 239 }, // x = 90
{ 115, 239 }, // x = 91
{ 115, 239 }, // x = 92
{ 115, 239 }, // x = 93
{ 115, 239 }, // x = 94
{ 115, 239 }, // x = 95
{ 115, 239 }, // x = 96
{ 115, 239 }, // x = 97
{ 115, 239 }, // x = 98
{ 115, 239 }, // x = 99
{ 115, 239 }, // x = 100
{ 115, 239 }, // x = 101
{ 115, 239 }, // x = 102
{ 115, 239 }, // x = 103
{ 115, 239 }, // x = 104
{ 115, 239 }, // x = 105
{ 115, 239 }, // x = 106
{ 114, 239 }, // x = 107
{ 114, 239 }, // x = 108
{ 114, 239 }, // x = 109
{ 114, 239 }, // x = 110
{ 114, 239 }, // x = 111
{ 114, 239 }, // x = 112
{ 114, 239 }, // x = 113
{ 114, 239 }, // x = 114
{ 114, 239 }, // x = 115
{ 114, 239 }, // x = 116
{ 114, 239 }, // x = 117
{ 114, 239 }, // x = 118
{ 114, 239 }, // x = 119
{ 114, 239 }, // x = 120
{ 114, 239 }, // x = 121
{ 114, 239 }, // x = 122
{ 114, 239 }, // x = 123
{ 114, 239 }, // x = 124
{ 114, 239 }, // x = 125
{ 114, 239 }, // x = 126
{ 114, 239 }, // x = 127
{ 113, 239 }, // x = 128
{ 113, 239 }, // x = 129
{ 113, 239 }, // x = 130
{ 113, 239 }, // x = 131
{ 113, 239 }, // x = 132
{ 113, 239 }, // x = 133
{ 113, 239 }, // x = 134
{ 113, 239 }, // x = 135
{ 113, 239 }, // x = 136
{ 113, 239 }, // x = 137
{ 113, 239 }, // x = 138
{ 113, 239 }, // x = 139
{ 113, 239 }, // x = 140
{ 113, 239 }, // x = 141
{ 113, 239 }, // x = 142
{ 113, 239 }, // x = 143
{ 113, 239 }, // x = 144
{ 113, 239 }, // x = 145
{ 113, 239 }, // x = 146
{ 113, 239 }, // x = 147
{ 113, 239 }, // x = 148
{ 112, 239 }, // x = 149
{ 112, 239 }, // x = 150
{ 112, 239 }, // x = 151
{ 112, 239 }, // x = 152
{ 112, 239 }, // x = 153
{ 112, 239 }, // x = 154
{ 112, 239 }, // x = 155
{ 112, 239 }, // x = 156
{ 112, 239 }, // x = 157
{ 112, 239 }, // x = 158
{ 112, 239 }, // x = 159
{ 112, 239 }, // x = 160
{ 112, 239 }, // x = 161
{ 112, 239 }, // x = 162
{ 112, 239 }, // x = 163
{ 112, 239 }, // x = 164
{ 112, 239 }, // x = 165
{ 112, 239 }, // x = 166
{ 112, 239 }, // x = 167
{ 112, 239 }, // x = 168
{ 112, 239 }, // x = 169
{ 112, 239 }, // x = 170
{ 111, 239 }, // x = 171
{ 111, 239 }, // x = 172
{ 111, 239 }, // x = 173
{ 111, 239 }, // x = 174
{ 111, 239 }, // x = 175
{ 111, 239 }, // x = 176
{ 111, 239 }, // x = 177
{ 111, 239 }, // x = 178
{ 111, 239 }, // x = 179
{ 111, 239 }, // x = 180
{ 111, 239 }, // x = 181
{ 111, 239 }, // x = 182
{ 111, 239 }, // x = 183
{ 111, 239 }, // x = 184
{ 111, 239 }, // x = 185
{ 111, 239 }, // x = 186
{ 111, 239 }, // x = 187
{ 111, 239 }, // x = 188
{ 111, 239 }, // x = 189
{ 111, 239 }, // x = 190
{ 111, 239 }, // x = 191
{ 110, 239 }, // x = 192
{ 110, 239 }, // x = 193
{ 110, 239 }, // x = 194
{ 110, 239 }, // x = 195
{ 110, 239 }, // x = 196
{ 110, 239 }, // x = 197
{ 110, 239 }, // x = 198
{ 110, 239 }, // x = 199
{ 110, 239 }, // x = 200
{ 110, 239 }, // x = 201
{ 110, 239 }, // x = 202
{ 110, 239 }, // x = 203
{ 110, 239 }, // x = 204
{ 110, 239 }, // x = 205
{ 110, 239 }, // x = 206
{ 110, 239 }, // x = 207
{ 110, 239 }, // x = 208
{ 110, 239 }, // x = 209
{ 110, 239 }, // x = 210
{ 110, 239 }, // x = 211
{ 110, 239 }, // x = 212
{ 109, 239 }, // x = 213
{ 109, 239 }, // x = 214
{ 109, 239 }, // x = 215
{ 109, 239 }, // x = 216
{ 109, 239 }, // x = 217
{ 109, 239 }, // x = 218
{ 109, 239 }, // x = 219
{ 109, 239 }, // x = 220
{ 109, 239 }, // x = 221
{ 109, 239 }, // x = 222
{ 109, 239 }, // x = 223
{ 109, 239 }, // x = 224
{ 109, 239 }, // x = 225
{ 109, 239 }, // x = 226
{ 109, 239 }, // x = 227
{ 109, 239 }, // x = 228
{ 109, 239 }, // x = 229
{ 109, 239 }, // x = 230
{ 109, 239 }, // x = 231
{ 109, 239 }, // x = 232
{ 109, 239 }, // x = 233
{ 108, 239 }, // x = 234
{ 108, 239 }, // x = 235
{ 108, 239 }, // x = 236
{ 108, 239 }, // x = 237
{ 108, 239 }, // x = 238
{ 108, 239 }, // x = 239
{ 108, 239 }, // x = 240
{ 108, 239 }, // x = 241
{ 108, 239 }, // x = 242
{ 108, 239 }, // x = 243
{ 108, 239 }, // x = 244
{ 108, 239 }, // x = 245
{ 108, 239 }, // x = 246
{ 108, 239 }, // x = 247
{ 108, 239 }, // x = 248
{ 108, 239 }, // x = 249
{ 108, 239 }, // x = 250
{ 108, 239 }, // x = 251
{ 108, 239 }, // x = 252
{ 108, 239 }, // x = 253
{ 108, 239 }, // x = 254
{ 108, 239 }, // x = 255
{ 107, 239 }, // x = 256
{ 107, 239 }, // x = 257
{ 107, 239 }, // x = 258
{ 107, 239 }, // x = 259
{ 107, 239 }, // x = 260
{ 107, 239 }, // x = 261
{ 107, 239 }, // x = 262
{ 107, 239 }, // x = 263
{ 107, 239 }, // x = 264
{ 107, 239 }, // x = 265
{ 107, 239 }, // x = 266
{ 107, 239 }, // x = 267
{ 107, 239 }, // x = 268
{ 107, 239 }, // x = 269
{ 107, 239 }, // x = 270
{ 107, 239 }, // x = 271
{ 107, 239 }, // x = 272
{ 107, 239 }, // x = 273
{ 107, 239 }, // x = 274
{ 107, 239 }, // x = 275
{ 107, 239 }, // x = 276
{ 106, 239 }, // x = 277
{ 106, 239 }, // x = 278
{ 106, 239 }, // x = 279
{ 106, 239 }, // x = 280
{ 106, 239 }, // x = 281
{ 106, 239 }, // x = 282
{ 106, 239 }, // x = 283
{ 106, 239 }, // x = 284
{ 106, 239 }, // x = 285
{ 106, 239 }, // x = 286
{ 106, 239 }, // x = 287
{ 106, 239 }, // x = 288
{ 106, 239 }, // x = 289
{ 106, 239 }, // x = 290
{ 106, 239 }, // x = 291
{ 106, 239 }, // x = 292
{ 106, 239 }, // x = 293
{ 106, 239 }, // x = 294
{ 106, 239 }, // x = 295
{ 106, 239 }, // x = 296
{ 106, 239 }, // x = 297
{ 105, 239 }, // x = 298
{ 105, 239 }, // x = 299
{ 105, 239 }, // x = 300
{ 105, 239 }, // x = 301
{ 105, 239 }, // x = 302
{ 105, 239 }, // x = 303
{ 105, 239 }, // x = 304
{ 105, 239 }, // x = 305
{ 105, 239 }, // x = 306
{ 105, 239 }, // x = 307
{ 105, 239 }, // x = 308
{ 105, 239 }, // x = 309
{ 105, 239 }, // x = 310
{ 105, 239 }, // x = 311
{ 105, 239 }, // x = 312
{ 105, 239 }, // x = 313
{ 105, 239 }, // x = 314
{ 105, 239 }, // x = 315
{ 105, 239 }, // x = 316
{ 105, 239 }, // x = 317
{ 105, 239 }, // x = 318
{ 105, 239 }, // x = 319
};
// ビットマップヘッダー (320x240x24bits color)
const uint8_t bitmap_header[] = {
0x42, 0x4D, 0x36, 0x84, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00,
0x00, 0x00, 0x40, 0x01, 0x00, 0x00, 0xF0, 0x00, 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x84, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
};
// -----=====<<<<<[[[[[ Prototype ]]]]]>>>>>=====-----
void camCB(CamImage img);
void analizeImage(rgb565_pixel_data_t* current, rgb565_pixel_data_t* previous);
void saveCaptureImage(rgb565_pixel_data_t* data);
String getCaptureFileName(void);
void drawAreaFrame(void);
bool isInArea(int x, int y);
uint8_t rgb565toGrayscale(uint16_t);
void audioAttentionCb(const ErrorAttentionParam *at_prm);
void playWarningSound(void);
void setup();
void loop();
// -----=====<<<<<[[[[[ Functions ]]]]]>>>>>=====-----
// カメラ画像取得コールバック
void camCB(CamImage img)
{
if (img.isAvailable()) {
// 画像フォーマット変換
img.convertPixFormat(CAM_IMAGE_PIX_FMT_RGB565);
// LCDに描画
display.drawRGBBitmap(0, 0 /* 開始座標 */
, (uint16_t*)img.getImgBuff() /* 画像データ */
, IMAGE_WIDTH, IMAGE_HEIGHT); /* 横幅、縦幅 */
// 解析処理
analizeImage(&rgb_image, (rgb565_pixel_data_t*)img.getImgBuff());
// 次回の解析の為に前回動画として保存する
memcpy((void*)&rgb_image.pixel_data[0][0], (void*)img.getImgBuff(), sizeof(rgb565_pixel_data_t));
}
}
// 画像解析処理
void analizeImage(rgb565_pixel_data_t* current, rgb565_pixel_data_t* previous)
{
int x, y;
uint8_t current_gray, previous_gray;
int diff_gray;
int count_over_threshold = 0;
drawAreaFrame();
for (int y = 0; y< IMAGE_HEIGHT; y++) {
for (x = 0; x < IMAGE_WIDTH; x++) {
current_gray = rgb565toGrayscale(current->pixel_data[y][x]);
previous_gray = rgb565toGrayscale(previous->pixel_data[y][x]);
diff_gray = abs((int)current_gray - (int)previous_gray);
if ( (diff_gray >= MOTION_DETECT_THRESHOLD) && (isInArea(x, y) ) ) {
if (is_debug_mode) display.drawPixel(x, y, ILI9341_PINK); // デバッグ用にスレッショルドを超えたピクセルを塗りつぶす
count_over_threshold++;
}
grayscale_image.pixel_data[y][x] = current_gray;
}
}
// 検知ピクセル数より大きい場合に、検知したとする。
if (count_over_threshold > detect_pixel_count) {
digitalWrite(PIN_LED_DETECT_INDICATOR, HIGH);
motion_detected = true;
debugPrintf("[DETECTED!!!] PixelCount=%d/%d > %d\n", count_over_threshold, pixels_count_in_area, detect_pixel_count);
if (is_image_save_mode) saveCaptureImage(current);
delay(1);
}
else {
motion_detected = false;
digitalWrite(PIN_LED_DETECT_INDICATOR, LOW);
}
}
// カメラ画像の保存
void saveCaptureImage(rgb565_pixel_data_t* data)
{
int x, y;
digitalWrite(PIN_LED_SAVE_INDICATOR, HIGH);
String file_name = getCaptureFileName();
if (file_name=="") return;// ファイル名が取得出来なかったら実行しない
// ファイルを開く
File img_file = the_sd.open(file_name, FILE_WRITE);
// BITMAPヘッダを書き込む (320x240 24bit色)
img_file.write(bitmap_header, ARRAY_SIZEOF(bitmap_header));
// BITMAPの順に上下逆転して、グレイスケールなので RGB各色に同じ値を書き込んでいく
for (y = 0; y < IMAGE_HEIGHT; y++) {
for (x = 0; x < IMAGE_WIDTH; x++) {
img_file.write(&grayscale_image.pixel_data[IMAGE_HEIGHT - y - 1][x], 1); // R
img_file.write(&grayscale_image.pixel_data[IMAGE_HEIGHT - y - 1][x], 1); // G
img_file.write(&grayscale_image.pixel_data[IMAGE_HEIGHT - y - 1][x], 1); // B
}
}
debugPrintf("Saving Capture Image : %s\n", file_name.c_str());
// ファイルを閉じる
img_file.close();
digitalWrite(PIN_LED_SAVE_INDICATOR, LOW);
}
// 画像の保存用のファイル名を取得
String getCaptureFileName(void)
{
String file_name;
static char str_buf[6];
int i;
// 上書きしないように、既存にない番号を検索してファイル名を決める。
for (i = save_image_file_num; i< 100000; i++) {
sprintf(str_buf, "%05d", i);
file_name = PHOTO_DIR "/img" + String(str_buf) + ".bmp";
if ( false == the_sd.exists(file_name)) break;
}
// 100000個以上は保存できないようにする。
if (i >= 100000) {
debugPrintf("File number overflow : %d\n", i);
file_name = "";
}
save_image_file_num++;
return file_name;
}
// 検知対象エリアの枠を描画
void drawAreaFrame(void)
{
int i;
for (i = 1; i < ARRAY_SIZEOF(area_pixel_table); i++) {
display.drawLine( area_pixel_table[i-1].x, area_pixel_table[i-1].y, area_pixel_table[i].x, area_pixel_table[i].y, ILI9341_RED);
}
}
// 座標が検知対象エリア内かを判定
bool isInArea(int x, int y){
bool is_in_area;
if ( (x >= 0) && (x < ARRAY_SIZEOF(area_range_table) ) ) {
is_in_area = ( (y >= area_range_table[x].min_val ) && ( y <= area_range_table[x].max_val ) )? true: false;
}
else {
is_in_area = false;
}
return is_in_area;
}
// RGB565 の 16bits データを 8bitグレイスケール値に変換する(単純に各成分の重みづけを調整して平均を取る)
uint8_t rgb565toGrayscale(uint16_t rgb565)
{
uint8_t gray;
gray = (uint8_t) (( ( ( ( rgb565 >> 11 ) & 0x1F ) * 255 / 0x1F ) +
( ( ( rgb565 >> 5 ) & 0x3F ) * 255 / 0x3F ) +
( ( ( rgb565 >> 0 ) & 0x1F ) * 255 / 0x1F ) ) / 3 );
return gray;
}
// Audioの警告コールバック
void audioAttentionCb(const ErrorAttentionParam *at_prm)
{
debugPrintf("audioAttention : ErrCode = %d\n", at_prm->error_code);
if (at_prm->error_code >= AS_ATTENTION_CODE_WARNING) audio_stopped_by_err = true;
}
// 警告音の再生
void playWarningSound(void)
{
int rc;
static bool is_playing = false;
static bool is_carry_over = false;
static size_t supply_size = 0;
bool reached_end = false;
// 既に再生中ならブロックする
if (is_playing) return;
is_playing = true;
digitalWrite(PIN_LED_SOUND_INDICATOR, HIGH);
#if defined(USE_BEEP_TO_PLAY)
the_audio->setBeep(1, PLAY_SOUND_BEEP_VOLUME, HUMAN_HEARABLE_FREQ);
delay(500);
the_audio->setBeep(0, 0, 0);
#else // defined(USE_BEEP_TO_PLAY)
// データ位置に移動
audio_file.seek(wav_data_offset);
// 先読みバッファリング処理
for (uint32_t i = 0; i < sc_prestore_frames; i++)
{
size_t supply_size = audio_file.read(audio_buffer, sizeof(audio_buffer));
wav_remain_data_size -= supply_size;
err_t err = the_audio->writeFrames(AudioClass::Player0, audio_buffer, supply_size);
if (err != AUDIOLIB_ECODE_OK) break;
if (wav_remain_data_size == 0) break;
}
// 再生開始処理
the_audio->setVolume(-100);
the_audio->startPlayer(AudioClass::Player0);
debugPrintln("Start Play");
do {
for (uint32_t i = 0; i < sc_store_frames; i++)
{
if (!is_carry_over)
{
supply_size = audio_file.read(audio_buffer, (wav_remain_data_size < sizeof(audio_buffer)) ? wav_remain_data_size : sizeof(audio_buffer));
wav_remain_data_size -= supply_size;
}
is_carry_over = false;
int err = the_audio->writeFrames(AudioClass::Player0, audio_buffer, supply_size);
if (err == AUDIOLIB_ECODE_SIMPLEFIFO_ERROR)
{
is_carry_over = true;
break;
}
if (wav_remain_data_size == 0)
{
the_audio->stopPlayer(AudioClass::Player0);
debugPrintln("Stop Play");
delay(100);
reached_end = true;
break;
}
}
if ((reached_end) || (is_carry_over)) break;
if (audio_stopped_by_err) {
the_audio->stopPlayer(AudioClass::Player0);
debugPrintln("Stop Play(Err)");
delay(100);
audio_stopped_by_err = false;
break;
}
usleep(1000);
} while(1);
#endif // defined(USE_BEEP_TO_PLAY)
digitalWrite(PIN_LED_SOUND_INDICATOR, LOW);
is_playing = false;
}
// 起動時初回処理
void setup()
{
err_t err;
// シリアルログ初期化
Serial.begin(115200);
while (!Serial); //Wait until Serial is Ready
// ピン関連の初期化
debugPrintln("Initializing Pin");
pinMode(PIN_DIPSW_IMAGE_IN, INPUT_PULLUP);
pinMode(PIN_DIPSW_IMAGE_OUT, OUTPUT);
pinMode(PIN_DIPSW_DEBUG_IN, INPUT_PULLUP);
pinMode(PIN_DIPSW_DEBUG_OUT, OUTPUT);
pinMode(PIN_DIPSW_HEARABLE_IN, INPUT_PULLUP);
pinMode(PIN_DIPSW_HEARABLE_OUT, OUTPUT);
digitalWrite(PIN_DIPSW_IMAGE_OUT, LOW);
digitalWrite(PIN_DIPSW_DEBUG_OUT, LOW);
digitalWrite(PIN_DIPSW_HEARABLE_OUT, LOW);
pinMode(PIN_LED_DETECT_INDICATOR, OUTPUT);
pinMode(PIN_LED_SAVE_INDICATOR, OUTPUT);
pinMode(PIN_LED_SOUND_INDICATOR, OUTPUT);
digitalWrite(PIN_LED_DETECT_INDICATOR, LOW);
digitalWrite(PIN_LED_SAVE_INDICATOR, LOW);
digitalWrite(PIN_LED_SOUND_INDICATOR, LOW);
delay(500);
// DIPスイッチの読み出し
is_image_save_mode = ( digitalRead(PIN_DIPSW_IMAGE_IN) == LOW)? true: false;
is_hearable_sound = ( digitalRead(PIN_DIPSW_HEARABLE_IN) == LOW)? true: false;
is_debug_mode = ( digitalRead(PIN_DIPSW_DEBUG_IN) == LOW)? true: false;
debugPrintf("DIP: Image=%d, Sound=%d, Debug=%d\n", is_image_save_mode, is_hearable_sound, is_debug_mode);
debugPrintln("Initializing SD (Waiting until SD card is inserted)");
while (!the_sd.begin()) ; //
// 検知対象エリアの画素数を数える
for (int x = 0; x < ARRAY_SIZEOF(area_range_table); x++) {
pixels_count_in_area += (area_range_table[x].max_val - area_range_table[x].min_val + 1);
}
detect_pixel_count = PIXEL_COUNT_THREAHOLD * pixels_count_in_area / 1000;
debugPrintf("Pixel Count = %d, Threshold = %d\n", pixels_count_in_area, detect_pixel_count);
// Display関連の初期化
debugPrintln("Initializing Display");
display.begin(); // 液晶ディスプレイの開始
display.setRotation(3); // ディスプレイの向きを設定
debugPrintln("Initializing Camera");
the_camera.begin(); // カメラの開始
the_camera.startStreaming(true, camCB); // カメラのストリーミングを開始
// Audio関連の初期化
debugPrintln("Initializing Audio");
#if defined(USE_BEEP_TO_PLAY)
the_audio = AudioClass::getInstance();
the_audio->begin(audioAttentionCb);
the_audio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, 0, 0);
#else // defined(USE_BEEP_TO_PLAY)
// ファイルを決める
if (!is_hearable_sound) audio_file_name = AUDIO_DIR DOG_WHISTLE_AUDIO_FILENAME;
else audio_file_name = AUDIO_DIR HUMAN_HEARABLE_AUDIO_FILENAME;
// ファイルからフォーマット情報を取得
fmt_chunk_t wav_fmt;
handel_wav_parser_t *wav_handle = (handel_wav_parser_t *)wav_parser.parseChunk(("/mnt/sd0" + audio_file_name).c_str(), &wav_fmt);
if (wav_handle == NULL)
{
printf("Wav parser error.\n");
exit(1);
}
// チャンクデータを探しておく
wav_data_offset = wav_handle->data_offset;
wav_remain_data_size = wav_handle->data_size;
wav_parser.resetParser((handel_wav_parser *)wav_handle);
// Audio初期化
the_audio = AudioClass::getInstance();
the_audio->begin(audioAttentionCb);
// ファイルのフォーマットにしたがってプレイヤーを初期化
the_audio->setRenderingClockMode((wav_fmt.rate <= 48000) ? AS_CLKMODE_NORMAL : AS_CLKMODE_HIRES);
the_audio->setPlayerMode(AS_SETPLAYER_OUTPUTDEVICE_SPHP, AS_SP_DRV_MODE_LINEOUT);
err = the_audio->initPlayer(AudioClass::Player0, AS_CODECTYPE_WAV, "/mnt/sd0/BIN", wav_fmt.rate, wav_fmt.bit, wav_fmt.channel);
if (err != AUDIOLIB_ECODE_OK)
{
printf("Player0 initialize error\n");
exit(1);
}
// ファイルを開く
audio_file = the_sd.open(audio_file_name, FILE_READ);
if (!audio_file)
{
printf("File open error\n");
exit(1);
}
audio_file.seek(wav_data_offset);
#endif // defined(USE_BEEP_TO_PLAY)
debugPrintln("Initialize DONE!");
}
// 定常処理
void loop()
{
if ( true == motion_detected ) playWarningSound();
delay(1);
}
3-2-3. 事前準備
3-2-3-1. 音声ファイル
犬笛周波数の超音波ハイレゾ音を収録した WAV データが必要です。 WaveGene という特定周波数の音声データを合成作成できる Windowsアプリケーションを使用しました。
3-2-3-1-1. 犬笛周波数音声
犬笛で使われる 30KHz の超音波を大音量の -10dB、静かなところでよく聞けばわずかに聞こえる音量の -60dBで、人間の可聴域である 3520Hz(7オクターブのラ) を混ぜて作成しました。
混ぜた理由は全く聞こえないと本当になっているか全くわからないからです。スピーカーに耳が付くほどに接近して聞こえるレベルです。
設定内容は以下に示します。
"sin3520_30k.wav" というファイル名を付けて、SDカードの [Audio] フォルダーの中に格納しました。
3-2-3-1-2. デバッグ用可聴音域音声
テスト時には音が聞こえないと不便なので、人間の可聴域である 3520Hz(7オクターブのラ) のみの音声をデバッグ用に作成しました。
設定内容は以下に示します。
"sin3520.wav" というファイル名を付けて、SDカードの [Audio] フォルダーの中に格納しました。
3-2-3-2. 検知領域定義テーブル作成
下記画像の赤枠のように、検出領域を定義してやる必要があります。
3-2-3-2-1. 検知領域範囲描画用テーブル ( area_pixel_table )
検知領域の範囲を赤枠で描くための座標テーブルです。5点を座標を描画順に並べています。(4点ではなく5点なのは閉ループにするためで、最初と最後が一致するようにしています。)
"3-2-3-3-1. 画像保存モード" を参考に、画像保存モードで動かしてやりカメラの画像を SDカードに保存し、その画像から枠の頂点座標を割り出して、 area_pixel_table の値を並べます。
3-2-3-2-2. 各x座標における、y座標の最低値と最大値 ( area_range_table )
検知領域の範囲を数値で表すためのテーブルで、各X座標における Y座標の最低値と最大値を示します。
最低値から最大値に含まれる範囲が、各X座標の範囲となります。
今回は計算は直線の公式を元に、Excel で計算しました。
まずは下記表のようにデータを設定していきます。("値/数式" の欄が "=" で始まるものは数式それ以外は数値。)
名称 | セル番号 | 値/数式 | 備考 |
---|---|---|---|
左端Y座標 | A1 | 120 | |
右端Y座標 | A2 | 105 | |
画像幅-1 | C1 | 319 | 画素数は変わらないので319固定でよい |
画像高-1 | D1 | 239 | 画素数は変わらないので239固定でよい |
X座標 | A2 | =ROW()-2 | |
Y座標計算式 | B2 | =INT(((($BAA2)/$CA$1) | |
ソース形式 | C2 | =" { "&B2&", "&D1&" }, // x = "&A2 |
その後、A2 から C2 をコピーし、A3 から A321 にペーストします。するとテーブルが出来ます。
C2 から C321 をコピーしてやり、 Arduino のソースに移動して "area_range_table" のテーブルを置き換えます。
数式を極めれば Excel でも、やや複雑な多角形でも定義可能になるかと思います。
3-2-3-3. デバッグ用機能
デバッグ用の機能としてジャンパー設定を用意しています。
下記の通りJP13 の特定のピンを、ジャンプワイヤー等でショートしてやることで、デバッグ機能が有効になります。
(普通はグランドコモンにしてそこからジャンパーを飛ばすのでしょうが、今回はピンはたくさん余っていたので…)
3-2-3-3-1. 画像保存モード
JP13 D07 - D06 をショートして起動するとこのモードになります。
元々学習データを保存するために作った機能です。
動体検出が行われたときにその時のカメラ画像をグレイスケールの BMP ファイルとしてSDカードへ保存します。
SDカードの [Photo] フォルダーに保存されます。
検出後に保存処理が走ることになるので、その間は動作が止まりますので、少し間延びした感じの処理になってしまいます。
3-2-3-3-2. 可聴音モード
JP13 D02- D03 をショートして起動するとこのモードになります。
元々再生する音声データは、犬笛の周波数で人間の聞こえない周波数帯であるため、鳴っているのか鳴っていないかの判断が付きません。開発中はわかりやすくするために、人間の可聴周波数の再生ができるようにしました。
また、デモ動画では聞こえない周波数ではわかりにくいので、わざと可聴周波数にて録画しました。
3-2-3-3-3. 検出点表示モード
JP13 D00- D01 をショートして起動するとこのモードになります。
前回との輝度の差分が一定以上になって、検出画素と認識された部分をピンク色で塗りつぶします。
検出点の状況を可視化してデバッグするのと、動画デモ用に作成したモードです。
動画再登場になりますが、"3-2-1. 処理方式" の所で説明に使った動画は、このモードで撮影したものです。
[検知可能領域内で前回との輝度差分が特定量を超える画素の様子をデバッグした動画]
3-2-4. 動作デモ
それでは動作デモの動画をご覧いただきます。
[動作デモ動画]
画面下半分の敷地領域内に人が侵入してきたときに、音がなっているのが確認できます。
音が聞こえるのはデバッグジャンパーで可聴域音声設定としているためです。
領域にちょっと入ったくらいでは反応しないのと、若干反応が遅いのはまだまだ調整の余地ありです。
3-3. 機構部
検出領域の定義を調整する必要があり、しっかりカメラの画角を固定し画面を見ながら調整する必要が出てきました。
そこでしっかり固定できるような機構を作成しました。
そして、追い込み時期に入った頃から、毎週末台風に襲われましたが、雨風も防げるようにケースも作ってやりました。
殆どは 3D プリンタで作りました。3Dモデリングはまだまだ初心者なのでかなり苦労しました。
データについてはこのサイトに埋め込める方法がないのと、自分の環境に合わせてつくったほうが良いかと思いますので割愛し、各部の写真だけ紹介します。
4. 終わりに
今回のコンテストでは終始メモリ不足に悩まされました。
特に各部が少しずつ出来上がった時点で組み合わせてみた時に地雷を踏んで全く動かない状況が長期間続き気が付いたら、締め切り間近という状況で、障害を全て解決できませんでした。
結果としては、まだまだ完全とは言えない実用には耐えきれないシステムで、改善の余地があると思います。大きく以下の様な課題が残っていると思っています。
まず、AI による動物の判別まで本当にできないのか確認が必要かとおもいます。
検出アルゴリズムですが、画素数割合を固定で使っているが、画素の位置によりカメラからの距離を加味してある程度の補正をかける必要がありそうです。
検出領域の設定をもう少しやりやすくしたいです。
音声再生時の警告の原因と対策が必要です。
その他考えるといろいろありそうです。
当初の目標からすると不完全とはいえ、それなりの膨大な手間暇をかけてもいて、何も投稿せず失格となるのは悔しすぎるので、できたところまでで記事を書いて応募させて頂きましたが、ご了承ください。
コンテストの結果が出るまでは編集禁止とのことですが、解除されて落ち着いたら少し改良も進めていければと思っています。
投稿者の人気記事
-
TakSan0
さんが
2022/09/26
に
編集
をしました。
(メッセージ: 初版)
-
TakSan0
さんが
2022/09/26
に
編集
をしました。
(メッセージ: 誤字修正+ソースコメント修正)
ログインしてコメントを投稿する