最近の記事
活動報告 No.155 春休みの出来事1(2月編)
カテゴリ: 未分類

お久しぶりです!ブログ担当代理のイトウです(=゚ω゚)ノ


2ヶ月もあった春休みもあっという間に終わって、

もうすっかり暖かくなってもう4月になってしまいました…
ということで春休みの出来事を振り返っていきたいと思います(^∇^)


まず、2月10日にKOMNDO BATTLEに行ってきました 前回まで同様KOTOBUKIYA ホールで行われました。

我々 ヒューマノイド研究からは3年生のODA先輩が学生選抜クラス、KHRクラスの2機体で出場しました!残念ながら2年生はロボジャパンに向けて調整中でした。


出場機体

プロテウス

プロテウス 

イプシロン

IMG_6319.jpg


学生選抜の試合から一部紹介します。


プロテウス VS >(デクレッシェンド)

序盤お互い攻め合うがダウンはお互い取れず接戦に
プロテウス攻撃耐えていくが、残念ながら1ダウンに
しかし残り1分で>(デクレッシェンド)がタイムをとり1対1になりました。
その後>(デクレッシェンド)による猛攻を受けるが>(デクレッシェンド)も倒れてしまいスリップ判定に、そのままタイムアップとなり引き分けとなりました。

IMG_6321.jpg

 
プロテウス VS Typerion

開幕早々 ダウンを取られてしまいました。
まさかの再スタート直後に大技の前転攻撃をくらい
2ダウンにより 敗北…
戦いなれている…

タイペリオン


KONDO BATTLE出場者の方々は機体の性能はもちろんですか、バトルの経験値の差を感じた大会でした。我々ヒュー研も部内戦を通してもっとバトルの練習していくべきかなと思いました(・ω・)

春休みの出来事まだ続きます!

それでは、次回ロボットジャパン編でお会いしましょうヽ( ´_`)丿



スポンサーサイト
編集 / 2018.04.04 / コメント: 0 / トラックバック: 0 / PageTop↑
活動報告 No.154 ElectronでD-WARS用のタイム・ポイントカウンタを作る
カテゴリ: 未分類

どうもおはこんばんにちは,もうすぐ無事3年になれる2年のマエダです.


最近は近藤バトルやロボジャパン,ロボワン,D-WARS8と色々ブログ記事になりそうなこといっぱいありましたが,大会報告に関しては他の人に執筆を頼んであるので,その方がアップするまでしばしお待ちを...(あれ,何週間前だろう?)


さて,今回も奇妙なタイトル発進ですが,要はオリジナルのタイムカウンターを作ってみた!てことです.


Electronて何

Electronを使うと,HTML・CSS・JavaScript・+αを使ったWEBサイト用のGUIアプリケーションを,macOS・Windows・Linux用デスクトップアプリケーションにパッケージングすることができます.


いやーデスクトップアプリケーションっていうとOSによってソースコード(というか言語そのもの)がかなり異なるので使用環境の多様性を考えると手をつけ難いもんでしたが,Electronを使えば大半のコードを共有して各OSに合わせたアプリが開発できちゃうんですねー.


1_wOcHbpZ25WbtWWsGI2b1Kw.png 

今回は超シンプルに,HTML・CSS・JavaScriptだけでタイム・ポイントカウンタを作っちゃいます!



諸注意

以下を前提としています.

・HTML,CSS,JS(JavaScript)に対する若干の文法とコードスタイル知識

・コマンドラインでの操作(パッケージインストール等)経験者



Electronをはじめるなら

この記事はコード紹介が主なので,作り方がイチから全部分かるってことはないです.それしたら何記事できてしまうのか...

私はドットインストールから始めました.大体この手の始めはドットインストールかプロゲートですね.


とりあえず手を動かして感覚掴んで,それから書籍をじっくり読み込むのが私の学習スタイルです.


ドットインストール

(・JavaScript入門:https://dotinstall.com/lessons/basic_javascript_v2)

・Electron入門:https://dotinstall.com/lessons/basic_electron


(あ,ステマじゃないですよ)



開発環境構築

※ macOSとUbuntu16.04LTSを想定


1,任意のディレクトリを作る

今回は「time_counter」というディレクトリ名でやっていきます.


2,Node.jsインストール

以下のサイトにアクセスして,Node.jsをインストールします.2つありますが,左側のLTS版を選ぶのが無難かと思われます.


https://nodejs.org/ja/


3,色々インストール

コマンドラインからtime_counterディレクトリまで移動します.

以下のコマンドを叩いていろんなヤツインストールします.


$ npm install electron1.6.1 --save-dev


これで同階層にnode_modulesディレクトリとpackage-lock.jsonファイルが生成されているかと思います.開発バージョンを柔軟にするために引数に「--save-dev」を付けてそのディレクトリのみにelectronを適用してあげます.



アプリつくろー

1,package.json

以下のコードを「package.json」として保存します.


{
"name": "time_counter",
"version": "0.1.0",
"description": "For D-WARS",
"main": "main.js",
"scripts": {
"start": "electron main.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Shimizu_mizu",
"license": "MIT",
"devDependencies": {
"electron": "^1.6.1"
}
}


パラメータ

"name":アプリケーション名

"version":まぁ何でも良いですが,開発段階は0.X台でいいかなと.

"description":アプリの簡易説明

"main":詳細は後述しますが,アプリ起動時最初に読み込まれるJSファイルです.アプリ全体のエントリーポイント(?)

"scripts" > "start":コマンドからより素早くElectronを起動するために必要です.

"author":作成者名

"license":個人でつくるんで,普段使っているMITでいきます


ちなみに,コマンドラインで


$ npm init


と叩けば対話形式でpackage.jsonをつくれます.


2,main.js

Electron製アプリケーションを起動する際,一番最初に読み込まれるJSファイルです.


// main process
'use stript';

const electron = require('electron');
const app = electron.app;
const BrowserWindow = electron.BrowserWindow;
const Menu = electron.Menu;
const dialog = electron.dialog;

let mainWindow;

let menuTemplate = [{
label: 'D-WARS',
submenu: [
{ label: 'About', accelerator: 'CmdOrCtrl+Shift+A', click: function() { showAboutDialog(); } },
{ type: 'separator' },
{ label: 'Quit', accelerator: 'CmdOrCtrl+Q', click: function() { app.quit(); } }
]
}];
let menu = Menu.buildFromTemplate(menuTemplate);

function showAboutDialog() {
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
message: 'このアプリケーションについて',
detail: '作成日 : 2018年3月5日\n作成者 : 東京電機大学理工学部学術文化部会ヒューマノイド研究部所属 16RT126 前田泰希\nバージョン : 0.1.0\n説明 : 本アプリケーションは,ヒュー研主催の二足歩行ロボット競技大会「D-WARS」向けに作られたタイム・ポイントカウンターです.'
});
}

function createMainWindow() {
Menu.setApplicationMenu(menu);
mainWindow = new BrowserWindow({
width: 600,
height: 400
});
mainWindow.loadURL('file://' + __dirname + '/index.html');
mainWindow.webContents.openDevTools();
mainWindow.on('closed', function() {
mainWindow = null;
});
}

app.on('ready', function() {
createMainWindow();
});

app.on('window-all-closed', function() {
if (process.platform !== 'darwin') {
app.quit();
}
});

app.on('activate', function() {
if (mainWindow === null) {
createMainWindow();
}
});


menuTemplate ではアプリ画面の左上によくあるメニューバーの表示名・ショートカット・クリック時の動作を記述しています.

{ type: 'separator' }, は区切りの横線です.

function showAboutDialog() では menuTemplate 'About'で表示する内容を記述しています.


mainWindow.loadURL('file://' + __dirname + '/index.html')でこのmain.jsとリンクするhtmlファイルを指定します.メインなので,WEBサーバで最初に読み込まれるindex.htmlという名前にするのがわかりやすくて良いと思います.__dirnameはこのmain.jsの同階層パスです.なので,このJSファイルを,例えば「time_counter/src/renderer/main.js」と「time_counter/index.html」というファイル構造なら,index.htmlはmain.jsから見て2階層上にあるので,記述すべきロードパスは'file://' + __dirname + '/../../index.html'となります.


mainWindow.webContents.openDevTools(); は開発時のみ使います.エラーや注意,デバッグが可能になります.アプリケーション配布時はこの行をコメントアウトするか,削除してからパッケージングします.


3,index.html

以下が中身です.


<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="UTF-8" />
<title>time_counter</title>

<link rel="stylesheet" type="text/css" href="./css/all.css">
<link rel="stylesheet" type="text/css" href="./css/index.css">
</head>

<body>
<h2 style="text-align: center;">タイムカウンタ</h2>
<ul id="access">
<li>
<a href="./contents/obstacle_race.html">> 障害物競争</a>
</li>
<li>
<a href="./contents/dice_battle.html">> さいころバトル</a>
</li>
<li>
<a href="./contents/robot_league.html">> ロボットリーグ</a>
</li>
<li>
<a href="./contents/battle.html">> バトル</a>
</li>
<li>
<a href="./contents/check.html">> 音量確認</a>
</li>
</ul>
</body>

</html>


複数競技があるので,トップでまず競技選択します.あとはHTMLが読めれば単純な話ですね.


4,カウンタコード(バトル用のみ)

タイム・ポイント両方をカウントしているバトルを例にとって解説です.


とりあえず「contents/check.html」と「js/all.js」を最初にお見せすると.


<!DOCTYPE html>
<html lang="ja">

<head>
<meta charset="UTF-8" />
<title>time_counter</title>

<link rel="stylesheet" type="text/css" href="../css/all.css">
<link rel="stylesheet" type="text/css" href="../css/battle.css">
</head>

<body>
<p id="displayArea">残り<span id="displayTimes"></span></p>
<p id="pointDisplayArea"><span id="displayBluePoints"></span> - <span id="displayRedPoints"></span></p>

<div class="buttonBlock">
<a href="../index.html" id="return">トップへ戻る</a>
<input type="button" value="初期設定" id="settingButton" onclick="setting(180, 0, 0);">
<input type="button" value="開始" id="startButton" class="timeButton" onclick="startCount();">
<input type="button" value="一時停止" id="stopButton" class="timeButton" onclick="stopCount();">
<input type="button" value="再開" id="restartButton" class="timeButton" onclick="restartCount();">
<input type="button" value="リセット" id="resetButton" class="timeButton" onclick="resetCount();">
</div>
<div class="buttonBlock">
<input type="button" value="blue-" id="dePointBlueButton" class="pointButton" onclick="addPoints('blue', -1);">
<input type="button" value="blue+" id="plusPointBlueButton" class="pointButton" onclick="addPoints('blue', 1);">
<input type="button" value="red+" id="plusPointRedButton" class="pointButton" onclick="addPoints('red', 1);">
<input type="button" value="red-" id="dePointRedButton" class="pointButton" onclick="addPoints('red', -1);">
</div>

<script type="text/javascript" src="../js/all.js"></script>
</body>

</html>



'use stript';


var passSec;
var firstTime = 0;
var bluePoints = 0;
var redPoints = 0;

var startButton = document.getElementById("startButton");
var stopButton = document.getElementById("stopButton");
var restartButton = document.getElementById("restartButton");
var resetButton = document.getElementById("resetButton");

var audio = new Audio('../audio/Opening_Buzzer02-1.mp3');


function setting(sec, bNum, rNum) {
firstTime = sec;
var msg = firstTime + "";
displayTimes.innerHTML = msg; // 表示更新

bluePoints = bNum;
redPoints = rNum;
msg = bluePoints + "";
displayBluePoints.innerHTML = msg;
msg = redPoints + "";
displayRedPoints.innerHTML = msg;
}


function blockButton(state) {
startButton.disabled = state[0];
stopButton.disabled = state[1];
restartButton.disabled = state[2];
resetButton.disabled = state[3];
(state[0]) ? setOpacity(startButton, 0.2) : setOpacity(startButton, 1.0);
(state[1]) ? setOpacity(stopButton, 0.2) : setOpacity(stopButton, 1.0);
(state[2]) ? setOpacity(restartButton, 0.2) : setOpacity(restartButton, 1.0);
(state[3]) ? setOpacity(resetButton, 0.2) : setOpacity(resetButton, 1.0);
}


// 要素の不透明度を操作
function setOpacity(elem, op) {
// IE6.0, IE7.0
elem.style.filter = 'alpha(opacity=' + (op * 100) + ')';
// Firefox, Netscape
elem.style.MozOpacity = op;
// Chrome, Safari, Opera
elem.style.opacity = op;
}


// 開始ボタン以外無効化
var buttonState = [false, true, true, true];
blockButton(buttonState);


// カウントが最後まで行ったら終了ブザーを鳴らす
function endCount() {
passSec = 0;
var msg = passSec + "";
displayTimes.innerHTML = msg; // 表示更新
audio.play();
}


// 表示内容
function showPassage() {
passSec--; // カウントダウン
if (passSec <= 0) {
stopCount();
endCount();
}
var msg = passSec + "";
displayTimes.innerHTML = msg; // 表示更新
}


// 開始
function startCount() {
passSec = firstTime; // カウンタのリセット
PassageID = setInterval('showPassage()', 1000); // タイマーをセット(1000ms間隔)
buttonState = [true, false, true, false];
blockButton(buttonState);
}


// 一時停止
function stopCount() {
clearInterval(PassageID); // タイマーの停止
buttonState = [true, true, false, false];
blockButton(buttonState);
}


// リスタート
function restartCount() {
PassageID = setInterval('showPassage()', 1000); // タイマーをセット(1000ms間隔)
buttonState = [true, false, true, false];
blockButton(buttonState);
}


// リセット
function resetCount() {
passSec = firstTime;
var msg = passSec + "";
displayTimes.innerHTML = msg; // 表示更新
clearInterval(PassageID); // タイマーの停止
buttonState = [false, true, true, true];
blockButton(buttonState);
}


// 秒数の加減
function addSec(num) {
passSec += num;
var msg = passSec + "";
displayTimes.innerHTML = msg; // 表示更新
}


// 得点の加減
function addPoints(type, num) {
if (type === 'blue') bluePoints += num;
if (type === 'red') redPoints += num;
msg = bluePoints + "";
displayBluePoints.innerHTML = msg;
msg = redPoints + "";
displayRedPoints.innerHTML = msg;
}


HTML内で記載されているinputタグは,

<input type="button" value="表示文字" onclick="起動するJSの関数名(引数)">

のルールにしたがって記述しています.


「js/all.js」はHTMLのボタンで呼び出す関数をつらつら書いているだけです.JSの暗黙の型変換やコメント,関数を細かく記述しているので,JS読めると多分わかるかと..


タイムカウントが最後までいったらvar audio = new Audio('../audio/Opening_Buzzer02-1.mp3');が呼び出されます.ちなみにブザー音です.


ここまでくれば何となく分かると思いますが,各ファイルは以下の役割を果たしています.

package.json:Electronの設定

main.js:ElectronのためのJSファイル

index.html:メイン

battle.html:タイム・ポイントカウント画面

all.js:動作関数群


WEBサイトで同様のアプリケーションを公開するなら,最低限

index.html

battle.html

all.js

が必要です.package.jsonとmain.jsが消えているだけです.

逆に,もしもHTML・CSS・JSで作ったWEBアプリケーションがあるなら,それをElectronを使ってデスクトップアプリケーションをつくるためには,Electronをインストールしてpackage.jsonとmain.jsを付け加えるだけでソースコードは出来上がってしまうのです.



パッケージング

macOS用の手順としては,まずアプリケーションのディレクトリ全てをasarというツールでアーカイブにします.んで,そのasarアーカイブをelectron-packagerというツールでアプリケーションにします.


↓実際のディレクトリ

sk.png 


1,asar

この子はグローバルにインストールして良いかと.


$ npm install -g asar


アーカイブ化するには,以下のルールに従います.


$ asar pack {アプリのディレクトリ} {出力名}.asar


例:

$ asar pack ./time_counter t_count.asar


2,electron-packager

こいつもグローバルでインストール.


$ npm i electron-packager -g


macOS向けパッケージング例

$ electron-packager ./time_counter time_counter --platform=darwin --arch=x64 -- electronVersion=1.4.13

Windows向けパッケージング例

$ electron-packager ./time_counter time_counter --platform=win32 --arch=x64 -- electronVersion=1.4.13



結果

time_counter.gif 

GIFにするとちょいとせっかちですが,ちゃんと動いています.状況によって触れられるボタンとそうでないボタンに分かれるので,誤操作を防ぐことができます.



しめ

最初にいった通り,WEB系の知識だけでいろんなOSに対応したデスクトップアプリが作れちゃう現代なので,誰でもお手軽にフリーソフト的なものを配布することもできちゃいます.

昔は「こういうフリーソフトないかなー」て思って窓の杜に行って見たりしてきましたが,今は比較的簡単に配布者側にまわれるのは感慨深いです.

Electronの元はWEBアプリなので,セキュリティにも注意を払わなくてはなりません.脆弱性をついて任意のコードを実行される恐れがありますから.責任を伴う配布は誰だって厳しいかもですね..






編集 / 2018.03.26 / コメント: 0 / トラックバック: 0 / PageTop↑
活動報告 No.153 公称ICS3.6未対応でもRCB-4HV経由で任意のサーボの現在角度を読み取ったり色々。。
カテゴリ: 未分類

明けましておめでとうございますぁあっす。さすがにヒュー研ブログをサボりすぎた2年のマエダです。
いや仕方ないんですよ…1月は期末期間で2月は大会ラッシュじゃないですか…。。

ま、新年の挨拶よりも言い訳が多くなりそうなのでさっさと本題に入ります。

近藤科学さんのサーボモータの通信規格に関して、ICS3.5以降はコマンドとPWMのどちらかで固定角度を決められますよね。そんなICS3.5ですが、ICS3.6は今まで主流だったICS3.5のコマンド群に加えて、任意のサーボの角度データを抽出できるコマンドが追加されました。
ICS3.5以下はどうやっていたのか明確な答えを見つけることができませんでしたが、サーボに目標値付コマンドを送った後の返り値を見たり、教示機能で読み取っていたんじゃないかと思います。

任意のサーボの角度データを抽出する技術ってのは、今まで皆さんが参加してきたであろうROBO-ONEとROBO-ONE lightから、最近新設されたROBO-ONE autoに機体を流用し易くなるものだと考えています。

あまりピンと来ないかと思いますが、ちょいと図解してみます。
まず、ROBO-ONEなど操縦者が人間の場合、機体とモーション、操作母体の関係は以下の通りです。

スライド1



そして、ROBO-ONE autoなど操縦権限がコンピュータの場合、機体とモーション、操作母体の関係は以下の通りです。


スライド2



 操作母体のやっていることは、「戦況によって限られたモーションデータのどれを再生するか判断している」です。条件分岐と言うことにすると、例えば、相手がちょいと遠ければ近づいて攻撃しよう、とか人間にも明示的ではないもののある程度の条件分岐を試合中にやっています。

ROBO-ONEからROBO-ONE autoに機体を流用しないとなったら、外界認識システムから基板、モーションの分岐、そもそもの機体に至るまでイチから機体を作り直すしかありません。しかし、もしもコンピュータからコントローラのコマンドを送れたらどうでしょうか。コンピュータは自律するコントローラと同等とみなせるので、ROBO-ONEの機体にCOM端子と電源だけ繋いで即ROBO-ONE auto仕様にすることにが出来てしまいます。

上記を理由に、コンピュータからRCB-4HVに適切なコントローラのボタンデータを送ったり、条件分岐するために任意のサーボの角度データを抽出したり指定したりできることは、auto用処理モジュールを製作するのに非常に有用なのです。



使用機器:

  • ラズパイ 3B
  • KRS-2542HV
  • RCB-4HV
  • Serial USB Adapter HS


です。USB経由でラズパイからRCB-4HVにコマンドを送信して、任意のサーボの角度データを持ってきてもらったり指定してあげたりします。



準備


ラズパイに近藤科学製のシリアルUSBを接続して、利用可能な状態にする必要があります。

近藤科学は以下の記事にてLinuxでシリアルUSBを利用する方法を紹介していますが、これではできません。注意です。

http://kondo-robot.com/faq/usb-adapter-for-linux


上記サイトは2010年に書かれたものです。今は以下の記事を参考にセットアップすれば一応使えます。

https://gpsnmeajp.blogspot.jp/2015/07/ftdi-ft232rlusbraspberry-pi.html


root権限じゃないと通らないです。スクリプト作って起動時に実行するようにすれば良いでしょう。



角度を指定する


コマンド方式で指定していきます。

コマンドのルールは以下の通りです。


[0x07, 0x0F, ICS番号, フレーム数, 角度データ(16進数で)下位, 角度データ(16進数で)上位, これ以前の総和]


1つめ:0x07

固定です。変える必要はありません。


2つめ:0x0F

ここも固定です。変える必要はありません。


3つめ:ICS番号

SIO端子が1〜4番ピンに刺さっていれば、「サーボID番号 × 2」がICS番号です。

SIO端子が5〜8番ピンに刺さっていれば、「サーボID番号 × 2 + 1」がICS番号です。


4つめ:フレーム数

まんまフレーム数です。1とか30とか60とか。。


5つめ・6つめ:角度データ(16進数で)下位・上位

近藤科学の定めるサーボの角度データは


35007500(ニュートラル)〜11500


です。これらを16進数で表すと、


0DAC1D4C(ニュートラル)〜2CEC


です。真ん中を例にとると、


角度データ(16進数で)下位 = 4C

角度データ(16進数で)上位 = 1D


です。言葉でいうと、近藤科学規格のサーボポジション数値の16進数版を前半と後半で2文字ずつ分けたとき、それぞれ上位と下位になります。


7つめ:これ以前の総和

1つめ〜6つめの数値を全て足し合わせたものです。


例:

例を示します。

・RCB-4HVの1〜4番ピンのどれかにサーボを接続してある

・サーボのIDは0番

・フレーム数は30

・サーボの目標位置は近藤規格で3500(-135度)

のとき、

[0x07, 0x0F, 0x00, 0x1E, 0xAC, 0x0D, 0xED]

というコマンドを送信すればサーボが3500の位置に動いてくれます。「0x」は16進数であることを示しています。ちなみに10進数で表すと、

[7, 15, 0, 30, 172, 13, 237]

です。

[0x07, 0x0F, ICS番号, フレーム数, 角度データ(16進数で)下位, 角度データ(16進数で)上位, これ以前の総和]

と比較してみてください。



ほんで、以下のプログラムをゴリゴリ書いていきます。Python3.5.3推奨です。ちなみにまだ未完成ですので、コピペは後でお願いします。


↓ 横スクロールできます ↓ ※ まだ未完成

import serial
from time import sleep


def set_pos(ser, sio_pin, servo_id, frames, deg):
# sioピンのチェック
if 1 <= sio_pin <= 4:
ics = servo_id * 2
elif 5 <= sio_pin <= 8:
ics = servo_id * 2 + 1
else:
return False

# 普通の角度か近藤規格の角度データかチェック
if -135 <= deg <= 135:
kondo_deg = 800.0 * deg / 27.0 + 7500.0
elif 3500 <= deg <= 11500:
kondo_deg = deg
else:
return False

deg_16 = hex(int(kondo_deg)) # 16進数に変換
deg_16 = deg_16[2:].zfill(4) # 0x以降を抽出して0パディング
deg_16_H, deg_16_L = int(deg_16[:2], 16), int(deg_16[2:], 16) # 上位と下位で分割
tail = 0x07 + 0x0F + ics + frames + deg_16_L + deg_16_H # 送信するコマンドの末尾に必要らしい(用途不明)
com_line = [0x07, 0x0F, ics, frames, deg_16_L, deg_16_H, tail] # 完成!!

ser.write(com_line)
sleep(2) # どう設定しようか。。

return True


def main():
ser = serial.Serial('/dev/ttyUSB0', 115200, parity=serial.PARITY_EVEN, timeout=1)
print('シリアルポートを開きました')

_ = set_pos(ser, 1, 0, 30, 0)

ser.close()
print('シリアルポート閉じました')


if __name__ == '__main__':
main()


set_pos関数にはPySerialでシリアル接続した際の返り値とsioピンの番号、サーボのID、フレーム、角度が引数として必要です。

上の例ではsioピン1番に接続されたID0のサーボをフレーム数30でニュートラルの位置まで動かしています。

-135 ~ 135の間で指定すれば自動的に単位が[度]として処理されますし、3500 ~ 11500の間であれば近藤規格のパラメータで処理されます。


set_pos関数の最後の方にあるsleepでは小数点使用可能で秒単位の待ち時間が指定できますが、どれくらい待てば良い

のやら。。例では適当にとりあえず2秒待っています。


動いてくれたら嬉しいっス。。


しかしバグ発生!!


先ほど送信コマンドのルールとして


[0x07, 0x0F, ICS番号, フレーム数, 角度データ(16進数で)下位, 角度データ(16進数で)上位, これ以前の総和]


と紹介しましたが、これの7要素目の「これ以前の総和」が256以上になると16進法で2バイトになってしまうので送信できませんでした。分割してもダメです。

RCB-4リファレンスを読み返してみると、


「チェックサム: 1byte⽬のサイズからチェックサムの1byte⼿前までのデタを加算した下位1byte」


と書いてありました。

というわけで、10進法で256以上の場合は下位1byteだけを抜き出してみます。


先ほどのset_pos関数を以下に書き換えます。


 
def set_pos(ser, sio_pin, servo_id, frames, deg):
""" 任意の1つのサーボをフレーム数と角度を指定して動かす """
# sioピンのチェック
if 1 <= sio_pin <= 4:
ics = servo_id * 2
elif 5 <= sio_pin <= 8:
ics = servo_id * 2 + 1
else:
return False

# 普通の角度か近藤規格の角度データかチェック
if -135 <= deg <= 135:
kondo_deg = 800.0 * deg / 27.0 + 7500.0
elif 3500 <= deg <= 11500:
kondo_deg = deg
else:
return False

deg_16 = hex(int(kondo_deg)) # 16進数に変換
deg_16 = deg_16[2:].zfill(4) # 0x以降を抽出して0パディング
deg_16_H, deg_16_L = int(deg_16[:2], 16), int(deg_16[2:], 16) # 上位と下位で分割
com_line = [0x07, 0x0F, ics, frames, deg_16_L, deg_16_H]

tail = 0x07 + 0x0F + ics + frames + deg_16_L + deg_16_H # 送信するコマンドの末尾に必要らしい(用途不明)
if tail >= 256:
# 10進数でいう256以上は16進数で2byteになるけど,下位を採用して切り抜ける
tail_16 = hex(tail)
com_line.append(int(tail_16[-2:], 16))
else:
com_line.append(tail)

ser.write(com_line)
_ = ser.readline()

return True




角度を読み取る


角度を読み取るときに使う特別なコマンドのルールはありません。


手順としては、まずHeartToHeart4のモーション作成パネルにて、「GetValue」を引っ張り出してきます。


キャプチャ



GetValueのブロックをダブルクリックしたら、以下の画面のように設定します。


キャプチャ2



現在値読み取りで返り値をCOMポートに出力します。ラズパイは4HVから返ってくる値を読み取れば良いだけです。


サーボの現在位置は人によって違うと思うので、ここは適宜対応お願いします。


設定したら、一番下にコマンドが出てくるので、メモしておきます。これが任意のサーボの角度を読み取るコマンドになります。


Python側のプログラムは関数だけ見せると以下になります。



↓ 横スクロールできます ↓

from time import sleep

def get_pos(ser, com_line, krs_deg=False):
""" コントローラに配置されたボタンを用いて任意のサーボの現在角度を読み取る """
for _ in range(100):
sleep(0.01)
ser.write(com_line)
data = ser.readline() # バイト列で受信
r_data = data.hex() # バイト列から16進数に変換
'''
r_data[0:2] = '05': 0x05はサーボの角度読み取り返り値を示す
r_data[2:4] = サーボモータのID番号 <- 使っていないけど一応
r_data[6:8] + r_data[4:6] = 角度データ(3500 ~ 7500:ニュートラル ~ 11500)
'''
if r_data[0:1] is '0' and r_data[1:2] is '5':
deg_16 = r_data[6:8] + r_data[4:6]
kondo_deg = int(deg_16, 16) # 16進数から10進数に変換
return kondo_deg if krs_deg is True else int(0.03375 * (kondo_deg - 7500))

return None



ざっとこんな感じです。0.01秒間隔で100回のループを行なっているのは、角度によって値が返ってくるまでの時間にばらつきがあるからです。



引用: http://kondo-robot.com/faq/ics3_6function

kondo.png 

上のグラフはICS3.6で角度を読み取るコマンドを発行したのち返ってくるまでの時間経過を表しています。

今回は4HV経由で指令を出していますが、上と同様なことが言えます。角度によって得たい数値が返ってくるまでにラグがほとんどなかったり0.5秒以上あったりです。なので、そこのばらつきを関数内にてfor文とsleepを使って吸収しています。


get_pos関数の引数にkrs_deg=Falseとありますが、これは返り値が近藤規格の角度データにするか否かを設定します。デフォルトのFalseであれば-135 ~ 135度の間で返ってきますし、Trueにすれば3500 ~ 11500の間で返ってきます。


get_pos関数は自律制御に使うカメラの角度に使おうとしているので、使用目的に合わなければ関数が宣言されている行の引数をkrs_deg=Trueとしてもらえればデフォルトで3500 ~ 11500の間で返ってきます。



今まで紹介してきた関数群の使用例は以下の通りです。


↓ 横スクロールできます ↓

import serial
from time import sleep


def set_pos(ser, sio_pin, servo_id, frames, deg):
""" 任意の1つのサーボをフレーム数と角度を指定して動かす """
# sioピンのチェック
if 1 <= sio_pin <= 4:
ics = servo_id * 2
elif 5 <= sio_pin <= 8:
ics = servo_id * 2 + 1
else:
return False

# 普通の角度か近藤規格の角度データかチェック
if -135 <= deg <= 135:
kondo_deg = 800.0 * deg / 27.0 + 7500.0
elif 3500 <= deg <= 11500:
kondo_deg = deg
else:
return False

deg_16 = hex(int(kondo_deg)) # 16進数に変換
deg_16 = deg_16[2:].zfill(4) # 0x以降を抽出して0パディング
deg_16_H, deg_16_L = int(deg_16[:2], 16), int(deg_16[2:], 16) # 上位と下位で分割
com_line = [0x07, 0x0F, ics, frames, deg_16_L, deg_16_H]

tail = 0x07 + 0x0F + ics + frames + deg_16_L + deg_16_H # 送信するコマンドの末尾に必要らしい(用途不明)
if tail >= 256:
# 10進数でいう256以上は16進数で2byteになるけど,下位を採用して切り抜ける
tail_16 = hex(tail)
com_line.append(int(tail_16[-2:], 16))
else:
com_line.append(tail)

ser.write(com_line)
_ = ser.readline()

return True


def get_pos(ser, com_line, krs_deg=False):
""" コントローラに配置されたボタンを用いて任意のサーボの現在角度を読み取る """
for _ in range(100):
sleep(0.01)
ser.write(com_line)
data = ser.readline() # バイト列で受信
r_data = data.hex() # バイト列から16進数に変換
'''
r_data[0:2] = '05': 0x05はサーボの角度読み取り返り値を示す
r_data[2:4] = サーボモータのID番号 <- 使っていないけど一応
r_data[6:8] + r_data[4:6] = 角度データ(3500 ~ 7500:ニュートラル ~ 11500)
'''
if r_data[0:1] is '0' and r_data[1:2] is '5':
deg_16 = r_data[6:8] + r_data[4:6]
kondo_deg = int(deg_16, 16) # 16進数から10進数に変換
return kondo_deg if krs_deg is True else int(0.03375 * (kondo_deg - 7500))

return None


def main():
ser = serial.Serial('/dev/ttyUSB0', 115200, parity=serial.PARITY_EVEN, timeout=1)
print('シリアルポートを開きました')

# 角度を読み取るコマンド(HTH4GetValueから要確認)
get_com_line = [0x0A, 0x00, 0x20, 0x00, 0x00, 0x00, 0x96, 0x00, 0x02, 0xC2]

_ = set_pos(ser, 1, 0, 30, 135) # 指定角度に固定
sleep(1)
d = get_pos(ser, get_com_line, krs_deg=False) # 現在角度検出

print('検出角度: ' + str(d))

ser.close()
print('シリアルポート閉じました')


if __name__ == '__main__':
main()



set_pos関数とget_pos関数の間にsleepを入れています。今は適当ですが、sleepをいい感じで入れないと、サーボが目標値に達する前に読み取りが呼び出されます。


一応set_pos関数内にser.readline()を入れてみたんですが、非ブロッキングなのかな?返り値が来るまで止められないっぽいです。


set_pos関数の仮引数にタイムアウトを置きつつ、返り値が来るまでwhile文で回して、返ってきたらブレイクするのがいいのかなぁ、と思ったり。。



おわり


てなわけで今回は公称ICS3.6未対応でもRCB-4HV経由で任意のサーボの現在角度を読み取ったり角度を指定したりをPythonで制御する記事でした。







編集 / 2018.02.11 / コメント: 0 / トラックバック: 0 / PageTop↑
活動報告 No.152 今年もありがとうございました! & 'C'と'CUDA'で自作ディープラーニング!!
カテゴリ: 通常

どうもおはこんばんにちは!
1週間の間にC&CUDAとJavaScriptとPythonでそれぞれ違うプロジェクトの為にプログラミングをしていた2年のマエダです。


〜〜〜

今年も残すところあと1日ほどとなってしまいましたねぇ〜。このヒュー研ブログの今年初めての記事は、1月5日の「活動報告No113 (近藤科学の)サーボホーンの話」という記事ですね。この記事は私の1つ上の学年の先輩の記事です。記事内でサーボホーンを持った骨(ボーン)が登場するのですが、ダジャレかと思ったら最初はそんなつもりなかったそうな。


んで、そのあと1月15日のが今年初の私の記事です。(活動報告No114 Webサイトを作ってみよう 1/3 ~学習編~)
懐かしいなあ。。結局ヒュー研の公式ホームページはこのようになりましたが、当時の私はコーディングスキルはあってもユーザビリティの概念が欠如していたので、なんとも幼稚なページを作成していた頃です。


現在は表向きのページは特にいじっていなくて、ヒュー研主催の関東学生2足歩行ロボットの初開催にむけての準備が進んできたら、そのページを新規で作ろうかなって思っているところです。


定期的に自分からWEB制作の案件というかその類をボランティアでやってWEBコーディングの技術を損なわないように努力してきたのですが、最近はまぁディープラーニングの方でCとCUDAばかり触っていることもあり、コーダーとしての自信を失いつつあります(笑)。来年の3月くらいに特に使用用途もないWEBサイト作ってみようかなと思っています。どこかから依頼があればそっちやりますが。


〜〜〜

話がWEBの方に行ってしまいましたが、今年はWEB系だけでなく、部活動を通して面白い体験などしました。後続にディープラーニングの記事が控えているので端的にまとめますが、去年と比べて大会や講演会等のイベントで多くの人とお話する機会がありました。ま、こういう言い方すると語弊がありますが、去年はそんな機会がなかったのではなく自分から作りに行ってなかっただけなんです。。


多くの人の中には何年もロボット製作に関わっている人であったり、最近始めた学生さん、ロボットをパートナーとした芸人さん、起業したエンジニアの方、研究室の先生などなど、、いち大学生としても、部の広報としても有意義な交流ができたんじゃないかと思いました。(だから他の部員たちも名刺作ろう)


〜〜〜

そういえば今年私が書いた記事はこれで23個目ですっ!来年も元気していたらちゃんとブログ書くはずなので、来年もどうぞよろしくお願いします!


それでは、良いお年を!(^∇^)ノ






なんかもう完全に締めな雰囲気ですが、ここからは興味のある人はどうぞご覧になってください。


今からやることは、以下の 画像1 に対して、ディープラーニングを用いて 画像2 のようにロボットを検出してもらおう!というプロジェクトです。



robo.jpeg 

画像 1



robo.jpg 

画像 2





今回作成したニューラルネットのコードは、擬似乱数生成のxorshift方式(論文はコチラ)以外は全て自分で書きました。

使用した言語はC言語とCUDAです。CUDAはNVIDIAのグラフィックボードを並列演算機として動作させるためのプログラムです。

C言語では標準で擬似乱数生成関数がありますし、より高品質と言われるメイセンヌツイスタも選択肢としてあった中でxorshiftをあえて選んだ理由は特にないです、ハイ。。計算が排他的論理和とビットシフトだけで「これだけ?」と思えるコードで十分実用的な擬似乱数を吐き出してくれるのを見て何か使いたいなあと思っただけです。


ニューラルネットはもちろん物体検出用のです。全体構造は完全畳み込みNNで出力テンソルが画像のグリッドに相当し、同層の各チャンネルがそれぞれグリッドの相対中心座標、画像全体の相対サイズ、物体の有無を表す信頼度、そして分類表示を持っています。知っている人は知っていますが、YOLOですね。ただ、私の作ったNNは完全なYOLOではなく、収束や精度に関する少々複雑な構造を取っ払ったシンプルなものです。そのため収束遅いし精度そんなよくないです。


ネットワークは以下の通りです。

層種 入力サイズ 入力チャンネル フィルタサイズ 出力サイズ 出力チャンネル
Conv 416 3(RGB) 3 416 32
MaxPool 416 32 2 208 32
Conv 208 32 3 208 64
MaxPool 208 64 2 104 64
Conv 104 64 3 104 128
MaxPool 104 128 2 52 128
Conv 52 128 3 52 256
MaxPool 52 256 2 26 256
Conv 26 256 3 26 512
MaxPool 26 512 2 13 512
Conv 13 512 3 13 1024
Conv 13 1024 3 13 12(BOX*5 + CLASS)

たったの12層しかないNNなので、計算量削減のための1x1畳み込み層は使っていません。あと、最大値プーリングの後でチャンネル数を倍にしています。

畳み込み層のストライドは1、最大値プーリング層のストライドは2です。各グリッドの持つアンカーボックスは2つとし、2クラス分類を考えるので、2*5+2=12が出力層のチャンネル数になります。


各畳み込み層の流れは以下の通りです。


順伝播データ

畳み込み

Leaky ReLU (slope=0.05)

バッチ正規化

addバイアス



各最大値プーリングのアップサンプリングは、順伝播時に記憶した最大値の場所を元に、誤差データをその場所に配置して、他は全て0になる方法を使いました。ちなみに本家YOLO(Darknet)はより正確な検出精度を出すために下層の誤差データを用いた(多分独自の)アップサンプリングをしています。


畳み込みとバイアスの更新および誤差データの算出はともかく、バッチ正規化の逆伝播が気になりますよね!(ますよね!)


別に私が解説する訳ではないですが、計算グラフを使って解いている人がいたので、そちらを見るとわかりやすいかと。めっちゃ丁寧に解説してくれていて好きです。


Bacth Normalization Backpropagation:

https://kratzert.github.io/2016/02/12/understanding-the-gradient-flow-through-the-batch-normalization-layer.html


上記のサイトではPythonとその強力なライブラリであるNumpyを用いて実装されています。記述量見ているとやっぱPythonいいよなぁ。PythonとPython用の機械学習ライブラリをそのままVHDLとかに落とし込めれば最高なのに。たまに変数がリストなのか何なのかわからなくなりますが(笑)。



じゃ、構造のあらかた提示したところでアノテーションを作っていきましょう。

アノテーションはDarknet方式とします。私は以前にDarknet専用のアノテーション作成補助ツールをGitHubにて公開したので、そちらを使います。


BB-Engine-forDarknet:

https://github.com/ShimizuMizu/BB-Engine-forDarknet


しかし今回はC言語で画像をリサイズする機能を実装していないので、アノテーション作成時に補助ツールがリサイズするようにします。また、読み込み形式はBMP形式です。BMP形式は素直にBGR値を保持しているので、データ容量が大きいというデメリットを除けば初心者には扱いやすい画像形式です!


任意にリサイズして保存するには、BB-Engine-forDarknet.pyの261行目を書き換えます。


# 変更前
shutil.copy(annotation_img_path, self.w_folder + "/" + annotation_img)

# 変更後
img = Image.open(annotation_img_path)
img_resize = img.resize((416, 416), Image.LANCZOS)
img_resize.save(write_img_path, 'bmp')



今回学習に使う画像データは冒頭に載せた、千葉工SKKさんのささペロリン(左)とヒュー研のテオ(右)の画像です。



tk_001.jpg tk_002.jpg


アノテーションは以下のように無事生成されました。


annotations_004.jpg robo-txt.jpg

ro.png 


ちゃんと画像が416x416にリサイズされました。今回はささペロリンを0番、テオを1番にラベル付けしました。

そして、今回は0番にラベル付けされた、ささペロリンのみを学習、検出しようと思います。



それじゃ早速...


教師データ_006 

教師データを書き込んで...


繧ソ繝シ繝溘リ繝ォ CMakeでMakefile生成してmakeでビルドして、いざ実行!!



言い忘れましたが、損失関数は残差平方和です。物体の大きさ、中心座標で一つの残差平方和を共有し、物体の有無に関する信頼は単体で、そして分類は全分類で一つの関数を共有しています。分類は収束を考えてsoftmax関数だか交差エントロピー誤差だかを使った方がいいのかもですね。


そんで、以下が損失の推移です。


損失の推移 


うん、まぁ。フィルタとバイアスの初期値はさっき紹介したxorshift方式を採用して -0.5〜0.5 の間で擬似乱数を生成していたので、それを考えるとちゃんと勾配を認識してくれたんじゃないかなと思います。


上のグラフからは読み取れないですが、一番最後の損失は 1.633719 でした。でかい!!



ほんじゃもっと詳細に見ていきましょうか。まずは物体の位置に関する出力テンソルです。


物体の存在可能性数値領域 



...とは言ってもこれだけではぱっと見わかりづらいですよね。


という訳で、出力が 0.5以上のマスを黄色 にします。


物体の存在可能性領域_スコア0,5以上 



確かにさっきのささペロリン君の位置に近いですね。


お次は中心グリッドです。


最も数値の大きいセル_物体の中心 


中心グリッドは閾値とかじゃなく、この169マスでもっとも大きな数値を黄色にしました。


さらに上の中心グリッドが保有する中心座標と物体の大きさを一気お見せします。


上_x_下_y 

▲ 上がx座標、下がy座標 ▲


上_width_下_height 

▲ 上が横幅、下が縦幅 ▲




以上の数値を可視化します。



出力可視化 


赤実線枠:物体の大きさ

赤破線枠:物体の中心が含まれているグリッド

赤丸:物体の中心点

黄色領域:物体のグリッド別領域(人間の方から閾値を設定可能)

と、こんな感じで、無事にそれっぽい場所をニューラルネットが予測(というか学習)してくれました。


見てもらった通り、中心座標や閾値で絞り込める領域についてはいい結果を出しているものの、物体の大きさを表す数値がイマイチですね。これはそもそも今回自作したニューラルネットが本家Darknetと比べて簡素化された学習であること、そして、12層しかネットワークを深くしていないことによる損失の収束の遅延が影響しているものと思われます。


人間の画像識別率を超えたというMicrosoft発ディープニューラルネットは156層もの階層構造になっているので、たった12層しかないのに場所と分類まで大まかにできたことを考えると、まあ上出来かなと思います。


この微妙な精度をどうにかするために、ニューラルネットの改善をこれから続けていこうと思います。学習途中には損失だけでなく、逆伝播毎のフィルタ・バイアスの最大・最小誤差の推移を観察していました。データとして残している訳ではありませんが、ちょくちょく見ていて、何となく上位層へ誤差を伝播しきれていない様子でした。上位層に行けば行くほど、誤差が小さいのです。


上位層ほど誤差が少なくなっている原因としては、最大値プーリング層のアップサンプリングに原因があると考えています。サイズ2のストライド2で順伝播をやると、最大値をそのまま返した場合、単純に考えると誤差は4分の1に減少してしまいます。本ニューラルネットでは高解像度の画像から13x13という比較的精細なグリッドに持ちこみ、物体検出をして見たいと思って先に紹介したような12層NNになったので、プーリング層の操作は学習にかなりの影響が出てきます。これについてはプーリング層を平均値プーリングにするか、最大値以外は0以上1未満の定数で商をとって逆伝播するか、と色々対策を考えています。



以上で長い後段の記事は終わりです。


今年中に自作ディープニューラルネットワークの結果報告ができてよかったです。今後はもっとCとCUDAと深層学習の勉強を続けてROBO-ONE autoにでも出てみたいですね!



てなことで、前段と重なりますが、みなさん良いお年を。


ほんじゃ、まったのぉ〜♪(/・ω・)/ ♪


編集 / 2017.12.30 / コメント: 0 / トラックバック: 0 / PageTop↑
活動報告 No.151 ROBOT GENERATION 12に参加してきました!
カテゴリ: 大会


どうもおはこんばんにちは、分身が2体ほど欲しい2年のマエダです。あまり詳しいことを言える段階でないのですが、今年末から来年度初めにかけてヒュー研内部が大きく変わりそうです。一部からはお怒りの声が届きましたが、ヒュー研をより良くしていくためのプロジェクトなので、数か月後にココで良いお知らせができたらなぁと思います。



さて、先日、12月17日(日)に明治大学生田キャンパスで開催された理科サークルフェスタの一環として行われた法政大学さん主催のROBOT GENERATION(ロボット・ジェネレーション)に参加してきました。(長い…)

今年の参加はヒュー研から5機体、千葉工さんのSKKから1機体、そして開催元である法政さんから2機体の計8機体でした。機体目は以下の通りです。

1・グレンジ (法政)
2・釣式 (電大)
3・墨 (電大)
4・ニマビ (電大)
5・テオ (電大)
6・メビウスK (電大)
7・ささペロリン (千葉工)
8・代理 (法政)

(敬称略)


千葉工さんはウチの元部長がニソコンでけしかけたのもあってか4機体エントリーだったそうなんですが、前日故障等重なり当日は1機体のみの参加だったそうな。


ロボジェネ12(2017_12_17)_10


釣り式(手前)




ロボジェネ12(2017_12_17)_21





ロボジェネ12(2017_12_17)_15


ニマビ




ロボジェネ12(2017_12_17)_23


テオ




ロボジェネ12(2017_12_17)_22


メビウスK




競技は全部で3種類、「障害物競走」・「ポイントアタック」・「バトル」の3つでした。






障害物競争


コースの概要は以下の通りです。

ロボジェネ12(2017_12_17)_19


~ 結果 〜



1・グレンジ 〜 回転バーまで
2・釣式 〜 残りタイム 0:13:36
3・墨 〜 残りタイム 1:54:13
4・ニマビ 〜 赤ペットボトルまで
5・テオ 〜 残りタイム 0:49:00
6・メビウスK 〜 残りタイム 0:24:62
7・ささペロリン 〜 残りタイム 1:09:48
8・代理 〜 回転バーまで



秒数は全て残りタイムなので、大きければその分早くゴールできたということです。今回の機体たちの中だと墨が一番早いですね。さすがです。

ニマビというのはヒュー研の今年の1年生が製作した(一応)小型機体なのですが、コース半分までしか行きませんでした。。ロボワンを考えるなら次回から予選あることだし歩行を洗練していきたいところですね。


ロボジェネ12(2017_12_17)_18


ロボジェネ12(2017_12_17)_17


ロボジェネ12(2017_12_17)_16






ポイントアタック


色々な形のスポンジたちを自陣に持っていくことでポイントが加算されて、最終的な合計点を競う競技です。



~ 結果 ~


試合

結果

第1試合

釣式 VS テオ
× - ○

第2試合

墨 VS ニマビ
○ - ×

第3試合

グレンジ VS ささペロリン
× - ○

第4試合

メビウスK VS 代理
○ - ×



ロボジェネ12(2017_12_17)_9


ロボジェネ12(2017_12_17)_11






バトル


いつも通りのバトル競技です。ルールは部内戦よりガバっていましたがガチというよりかは交流的な意味でやっている気持もあるので楽しめたらそれで良いでしょう。



~ 結果 ~


順位

機体

第1位

テオ

第2位

釣式

第3位以下

未決定




ロボジェネ12(2017_12_17)_8


ロボジェネ12(2017_12_17)_6

↑ エキシビションマッチ ↑






~ 最終結果 ~


第 1 位  テオ

第 2 位  墨

第 3 位  ささペロリン




最終結果は上のようになりました。おめでとうございます!




ロボジェネ12(2017_12_17)_4






次お会いするのはKONDOバトルかロボジャパンかROBO-ONEですかね。年をまたいでとなりますが、来年も元気にバトれること期待しています! 私は今年度のレポートが残り1つとなったので、ヒュー研の技術開発を加速させて、来年こそは自律機体を製作したいです。


今年はこれが最後のブログかな...?あと1つあるかな...?




ほんじゃまったの~(*・`ω´・)ゞ



編集 / 2017.12.22 / コメント: 0 / トラックバック: 0 / PageTop↑
プロフィール

ヒュー研の中の人

Author:ヒュー研の人
このブログは東京電機大学理工学部ヒューマノイド研究部の公式ブログです。2012年から部に昇格しました!
その日の活動や大会の記録をできるだけ更新していきたいです!!

☆だいたい金曜日前後に更新します☆

FC2カウンター
カレンダー
03 | 2018/04 | 05
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 - - - - -
リンク
ブロとも申請フォーム
携帯でみるには↓
QR