cv::Mat画像のサブピクセル画素値を取得
久々にOpenCVのTips
画像処理をしていると,テクセルサンプリングのように浮動小数点座標の画素値を取り出したい場合が時々出てきます.そういう時って普通,整数座標の画素値を使ってバイリニア補間するんですけど,なぜか OpenCVでは「特定の1点のサブピクセル画素を取得する」という処理を標準で用意していません(ちなみにcv::Mat::at<>()
メソッドの座標値に浮動小数点数を入れてもコンパイルエラーは起きませんが,実際には暗黙的にintに変換された座標値からサンプルされます).サブピクセル精度でコーナー座標を検出するcv::findCornerSubPix()
みたいに画像からサブピクセル座標を割り出す処理はあるんですが,今回やりたいのはその逆なんです.
ただし,サブピクセル精度で画像を切り出す処理ならあり,cv::getRectSubPix()
でできます.今回のTipsのアイディアは,これで得たいサブピクセル座標を画像中心として3x3画像で切り取り中央座標値をサンプルする,というものです.意外と気づかない手法だと思うので,書き残すことにしました.
// グレースケール画像mの要素をサブピクセル座標pから取得する // 通常のMat::at()メソッドでは四捨五入された座標から取得されてしまうのでcv::getRectSubPix()を利用する // 画像範囲外は境界線と同じとする // サポートする型はCV_32F or CV_8U double sampleSubPix(cv::Mat m, cv::Point2d p) { cv::Mat r; cv::getRectSubPix(m, cv::Size(3, 3), p, r); return (double)r.at<float>(1, 1); }
上のコードはグレースケール画像のみ対応ですが,同じ方法でカラー画像もできます.ただし,実はcv::getRectSubPix()
はCV_8U
もしくはCV_32F
にしか対応していない関数なので,要素がdoubleのCV_64F
画像はサポートしていません.その場合は仕方ないので一旦castしましょう.
透視投影変換にガンマをかけて嘘パースを実現
はじめに
今回の手法で次のような絵が撮れます.
本記事ではフリーのMMD描画アプリを利用して嘘パースの実験をしていますが,シェーダーレベルでアプリを改造する上,原理のみ紹介するので,CGデザイナよりもむしろエフェクトを開発するプログラマ向けの記事となります.予め御了承下さいませ.
嘘パースについて
皆さん,嘘パース(オーバーパース,漫画パース)というのはご存知でしょうか?
例えば,サンライズアニメでよく見かける,例の武器を画面いっぱいに突き出して構える迫力ある構図(サンライズパース)をCGアニメで再現したいとします.しかし,実寸で作られたCGに同じポーズをさせて普通に透視投影変換でレンダリングすると,あんなに大きく武器が映りませんのでちょっと迫力に欠けてしまいます.なぜそんなことが起こるかというと,サンライズパースでは正確な透視投影よりも手前の物体がより大きく映るようにあえて強調した投影に従って描いているからです.このように,通常の透視投影変換ではありえないくらい近い物体が大きく写っているような投影を嘘パースと呼びます.アニメや漫画では迫力があった構図なのにCGにすると物足りない,という現象の原因は嘘パースだったりします.
現在,CGアニメで嘘パースを実現する方法は,その構図専用の歪んだCGモデルを用意する というのが一般的です.でも,それってかなり面倒くさいですよね.だって同じキャラクターなのに超カッコいい構図の度に新しいCGモデルを用意しなきゃいけないんですから.他には,自動で人物CGモデルを局所的に肥大化させる手法(嘘パースプラグイン),画角の異なるカメラを複数用意してシームレスに映像を合成して実現する手法(マンガパース)などが知られています.
ここでは,より原理的な部分に着目して嘘パースを実現する手法を考案しました.それが,透視投影変換にガンマをかける という発想です.ディスプレイガンマのようにZ軸方向にガンマをかけることで,人間の知覚に近い歪み方をとっても簡単に再現できるのではないか?と思って試してみました.これは新しい投影変換を開発する必要がありますので,ちょっと考え方を示していきます.
原理
通常の透視投影
デザイン系の人には遠近法と言った方がわかりやすいかもしれないですが,透視投影とはすべての光線がある唯一の視点(原点)に集中するものとして考案された投影法です.
透視投影をCGで実現するには,レンダリング時に透視投影行列という同次変換行列(下式)をかませます.これによって,透視投影の視錐体がクリッピング空間に圧縮されるわけです.
透視投影にガンマをかける
透視投影では,物体の見た目の大きさは視点からの距離に反比例するという性質があります.3DCGプログラマにとっては非常に当たり前のことに思えますが,それはなぜでしょう?理由は,透視投影では光線が原点に直線的に集まると仮定しているからです.もちろん,この仮定は物理的には間違ってません.
対して嘘パースでは,視点の物体が通常の透視投影よりも極端に大きくなるという性質があります.透視投影では直線 に従って投影面 での見た目の大きさを決定していましたが,私はここでガンマ変換をかけることにしました.つまり,人が奥行きのあるシーンを観察したときに,スティーブンスの冪乘則に従って奥行知覚が実際の奥行きよりも強調される と仮定しました(これが事実かどうかは私は知りません).この時の投影は次のようになります.
このようにすると,同じ大きさの物体でも遠くにあれば投影面ではより小さく映り,逆に近い物体はより大きく映ることになります.具体的には,同じ物体の見た目の大きさはの逆数に比例します.
ガンマ透視投影変換
この考えに基づいて投影行列を再構成すると,次のようになります.ここではガンマ透視投影変換と呼ぶことにします.とすると元の透視投影行列と一致するはずです.プログラム的には,モデルビュー行列をかけた後に一旦Z軸を編集する必要があるのでちょっと面倒ですが,バーテックスシェーダーで難なく実装できるでしょう.
実験
今回はMMD on WebGLのソースコードを改変して,初音ミクの標準モデル(あにまさ式)で試してみました.この場を借りて作者様に感謝します.
結果
まずは通常の透視投影変換でレンダリングした結果がこちらです.
これとほぼ同じ構図,ポーズで でガンマ透視投影変換でレンダリングした結果が次のようになります.
狙い通り,前方に突き出した手がかなり強調される嘘パースが実現できました.
今回の目的とは異なりますが,逆に とガンマを1より小さくした場合どうなるかというと...
こうなります.奥の方の手が透視投影よりも大きく映ります.偶然かもしれませんが,平行投影に似ています.
注意点
上の結果はかなりうまくいってるようですが,実はガンマ透視投影では構図をうまく調節しないとすぐにひどい結果になります.
このように,画面の外側は激しく歪みやすいです.しかし改善策はあります.ガンマ透視投影行列の光軸中心(行列の3列目の上2個)をずらしてやることで,ある程度問題を回避できるようです.
上2つはほぼ同じ方向からほぼ同じポーズをレンダリングしたもので,上は光軸中心を中央に固定しており,下では光軸中心を顔付近に持ってきています.このように,光軸を中央からずらして調節することで,ほぼ違和感のない嘘パースが様々な構図で実現できるようです.
経験則ですが,初音ミクのような人体モデルの場合はガンマによって頭部が歪むと強く違和感を覚えるので,頭部だけは歪まないように構図を調節すると良いでしょう.
倒立振子制作日記(そのに)
PWMによる電流制御
前回の記事でも取り上げましたが,この記事によるとDCモータで出力制御系を構成するときは電圧入力ではなく電流入力にすると理論的扱いがしやすいとのことですので,私もこの記事と同様に電流入力系を構成しようと思います.(注:以下,超基本的な所から説明します)
ちょっと復習
DCモータにはT-I特性(トルクvs電流)とT-N特性(トルクvs回転数)というものがあり,トルクと電流は比例し,負荷トルクが増すと直線的に回転数が減少する,という特性があります(ちなみに電圧を変えるとT-N特性が平行移動します).つまり電流入力さえできればその時の負荷トルクさえ分かれば回転数(速度)が制御できることになります.
で,どーすんの?
さて一方で,例えDAコンバータを積んでいたとしてもマイコンにできるのはGPIOの電圧出力を変調することのみです.更に言うとArduinoUNOにはDACもないのでPWM出力をアナログ出力とみなすしかありません.モータの自己インダクタンスとか慣性とか非線形摩擦とかを考慮して,Hブリッジへの入力電圧だけでモータ入力電流値を予測するのは困難です.
そこで出てくるのが電流フィードバックです.これはモータへの入力電流を何らかの方法で計測し,その測定値をPWM出力にフィードバックして目標電流値を実現する,というものです.今回使用したモータドライバL298Nにはセンサ用PIN(と称するHブリッジの下流)がありますので,抵抗を挟んでGNDに接続し,抵抗間の電圧をアナログ入力に読ませることにしました.以前の記事では電流計測ICを用意していましたが,使い方が悪いのかセンサ自体の性能かわかりませんがノイズでかすぎて使い物にならなかったので抵抗にしました.
64種類 各20個(合計1280個)金属膜抵抗キット 1/4W(0.25W) 許容差±1%
- 出版社/メーカー: Ecowsera
- メディア: エレクトロニクス
- この商品を含むブログを見る
この抵抗セットがめちゃくちゃ便利でした.そんなに大量に使う予定がないなら,アキバでバラ100本入り100円とかで買うより良いかも.
コーディングする前に
電流センサ抵抗の選定
電流センサ抵抗の値はどうすればいいのか.これは中学生でも知っているオームの法則を利用します.下に今回の電流計測回路の簡単な回路図を示します.
Vss | [M] Motor (実際はHブリッジで接続) | |---- V (Analog Input) | I > ↓ > R > | | --- ///
マイコンのアナログ入力はハイインピーダンスであるため,Analog Inputへは殆ど電流が流れません.そのためモータ入力電流は抵抗電流 \(I\) で近似でき,オームの法則 \(V=RI\) を利用してモータ入力電流をアナログ入力から得ることができます.従って,Analog Inputの許容最大電圧とMotor入力として想定する最大入力電流が分かれば \(R\) の値を見積もれます.
ArduinoUno(互換機)のアナログ入力の許容最大電圧はマイコン電源電圧と同じ \(V_{cc}=5[V]\) であり,モータドライバL298Nの定格最大電流は \(I_{max} = 2[A]\) であるので,
\(R = V_{cc} / I_{max} = 5[V]/2[A] = 2.5[\Omega]\)
と求められます.しかし現実には電池の供給能力の関係上2[A]も出せませんし,消費電力を考えると抵抗は小さい方がモータ入力に割く電流が大きくとれ,制御に有利そうです.そこで抵抗値をもっと下げて \(R=1[\Omega]\) にしてみるとどうでしょう.すると,定格最大電流は変わらないので想定最大電圧は
\(V_{max} = RI_{max} = 1[\Omega] \times 2[A] = 2[V]\)
と,先程の5[V]よりだいぶ低くなってしまいました.マイコンのアナログ入力の最大値は変わらず5[V]ですので,これはセンサ入力の分解能が低下することを意味します.計算は省略しますが,逆に抵抗値を増大させてセンサの分解能を上げようとする場合,そもそもの制御量であるモータ入力電流の最大値が減少してしまいます(或いは,最悪の場合マイコンのアナログ入力回路を破壊する程の電圧を許容することになります).ということでここはバランスよく決めなくてはなりません.今回は手元に2.2[Ω]の抵抗があったので,最初の計算に近いということもありこいつを使います.
アナログ入力から電流値への変換
電子工作やPCに疎い人は分からないかもしれないので一応説明しておくと,実はマイコンのアナログ入力から直接返ってくる値は物理的な電圧値(実数)ではなく,それをADコンバータ(ADC)が読み取ってADCの精度(bit数)に応じて予め決められた最大値との比を整数値で返す,という動作をします.例えば,5[V]駆動の8bitADCに2[V]を入力すると,
\(256 \times {2[V] \over 5[V]} = 102.4 \simeq 102\)
という値が返ってきます.従ってこの逆算式
\(V = V_{max} \times {A \over A_{max}}\)
を通して物理値である電圧が得られますね(AはADCの出力).更にセンサ値(今回は電流値)に変換するには電圧とセンシングしたい値との関係式をデータシートから読み取って実装すればいい訳です.今回はその関係式とはオームの法則に他ならないので, \(I=V/R\) として先程選定した抵抗値をぶち込めば晴れて電流値を読み取ることができます.
フィードバック制御
今回は速度型PI制御器を構成しました.同じPID制御でも位置型と速度型があるようで,位置型は制御量uそのものをPIDにより求めるもの,速度型は制御量uの増分をPIDにより求めるものです.今回の場合は電圧で直接電流を制御するので,以前の電流入力を保持する構造が何もありません.従って位置型にすると目標値に達した瞬間に電圧が0になり電流が急低下するといった現象が起こると考えられるので,速度型を採用する方が良さそうです.電流もいわば速度だし.
というわけで,制御量の数式は以下のようになります.
\(
u_t = u_{t-1} + \Delta u, \)
\(\Delta u = K_pe_t + K_i\sum_t{e_t},\)
\(\space e_t = I_\theta -I_t
\)
フィードバックゲインは限界感度法っぽい感じで適当に決めました.
制御量のPWM出力への変換
制御量をどうやってPWM出力に変換するのかも悩みどころです.制御量uは電流から生成したので,duty比が最大になった時に想定した最大電流が流れると仮定して,次の変換式に掛けました.
\(duty \space cycle = 255 \times {u_t/I_{max}}\)
255というのはArduinoのPWM出力が8bitだからです.単位がごちゃごちゃだし,あとあと考えてみると変な仮定な気がしますが,電流を電圧にしたところでゲインが変わるだけだし,これで動いたのでとりあえず良しとしましょう.
Arduinoスケッチ
CurrentFeedback.h
#ifndef CURRENTFEEDBACK_H_ #define CURRENTFEEDBACK_H_ #include "Arduino.h" #define LOOP_MAX 20 // アナログ入力平均化ループの周期 #define ANALOG_MAX 1024 // アナログ入力は10bit #define DUTY_MAX 255 // デューティ比は8bit // 定数 // current_max = analog_voltage / current_resist = 2.27A const float analog_voltage = 5.0; // アナログ最大電圧[V](analog = 1024) const float current_resist = 2.2; // モータ電流抵抗[ohm] const float current_max = 2.27; // モータ印加電流最大値[A] class CurrentFeedback{ protected: // Pin int analog_pin; // 電流センサ int input1_pin; // 正回転 int input2_pin; // 逆回転 int enable_pin; // PWM出力 // 内部変数 float current_target; // モータ印加電流の目標値[A] float Kp_current; // 電流比例フィードバックゲイン float Ki_current; // 電流積分フィードバックゲイン float error_integral; // 誤差積分値 float pwm_duty; // PWM出力のデューティ比(-255.0 ~ 255.0) public: // コンストラクタ CurrentFeedback(){ } CurrentFeedback(int analog, int input1, int input2, int enable, float feedback_gain_p = 1.0, float feedback_gain_i = 1.0){ begin(analog, input1, input2, enable, feedback_gain_p, feedback_gain_i); } // デストラクタ ~CurrentFeedback(){ } // 初期化関数(PIN, フィードバックゲインの登録) void begin(int analog, int input1, int input2, int enable, float feedback_gain_p = 1.0, float feedback_gain_i = 1.0){ // 引数の保存 analog_pin = analog; input1_pin = input1; input2_pin = input2; enable_pin = enable; Kp_current = feedback_gain_p; Ki_current = feedback_gain_i; // 値の初期化 current_target = 0; pwm_duty = 0; error_integral = 0; } // 目標値の登録 void setTargetCurrent(float target_current){ current_target = target_current; } // 現在の測定値を読みだす float getCurrent(){ // ADCの平均出力値を算出 float analog_mean = 0; for(int i=0;i<LOOP_MAX;i++){ int a = analogRead(analog_pin); analog_mean += (float)a/LOOP_MAX; } // ADC出力値から電流値への変換 float current_sense = analog_mean / ANALOG_MAX * analog_voltage / current_resist; current_sense = (digitalRead(input1_pin) == HIGH)? current_sense : -current_sense; // 印加電流の符号を決定 return current_sense; } // 登録した目標値に向けてフィードバック制御 void output(){ // sensor float current_sense = getCurrent(); // モータ印加電流の実測値[A] 符号は回転方向 // controller // フィードバックコントローラ 速度型PI制御 float current_error = current_target - current_sense; // 誤差 error_integral += current_error; float control_input = Kp_current * current_error + Ki_current * error_integral; // PI制御 pwm_duty += control_input / current_max * DUTY_MAX; // current -> duty cycle(-256 ~ 255) if(pwm_duty > DUTY_MAX) pwm_duty = DUTY_MAX; else if(pwm_duty < -DUTY_MAX) pwm_duty = -DUTY_MAX; int pwm_dst = (int)abs(pwm_duty); // actuator // モーター回転方向 if(pwm_duty > 0) { digitalWrite(input1_pin, HIGH); digitalWrite(input2_pin, LOW); } else if(pwm_duty < 0) { digitalWrite(input1_pin, LOW); digitalWrite(input2_pin, HIGH); } else{ digitalWrite(input1_pin, LOW); digitalWrite(input2_pin, LOW); } analogWrite(enable_pin, pwm_dst); } // 緊急停止 void quickStop(){ current_target = 0; pwm_duty = 0; digitalWrite(input1_pin, HIGH); digitalWrite(input2_pin, HIGH); analogWrite(enable_pin, 255); } }; #endif // CURRENTFEEDBACK_H_
こんな感じでクラス化してみました.Arduino IDEでクラスを書くのは初めてだし,ArduinoでC++記法がどこまで許されてるのか分からなかったのでちょっと大変でしたが,なんとかうまくいきました.やっぱC言語よりクラスが書けるC++の方が慣れると便利ですね.
動作テスト用スケッチ
#include "CurrentFeedback.h" #define MOTOR1_INPUT1_PIN 4 // Input 1 #define MOTOR1_INPUT2_PIN 5 // Input 2 #define MOTOR1_PWM_PIN 6 // Enable 1 #define MOTOR1_CURRENT_PIN 1 // Analog 1 CurrentFeedback current1; float current_target = 0; void setup() { // put your setup code here, to run once: Serial.begin(19200); Serial.println("hello"); current1.begin(MOTOR1_CURRENT_PIN, MOTOR1_INPUT1_PIN, MOTOR1_INPUT2_PIN, MOTOR1_PWM_PIN, 1.2, 0.4); } void loop() { // put your main code here, to run repeatedly: // シリアル入力(目標値の決定) char c = Serial.read(); if(c == '1'){ if(current_target > -current_max){ current_target -= 0.05; } else{ current_target = -current_max; } Serial.println(current_target, DEC); } if(c == '2'){ if(current_target < current_max){ current_target += 0.05; } else { current_target = current_max; } Serial.println(current_target, DEC); } if(c == '3'){ current_target = 0; Serial.println(current_target, DEC); } if(c == '4'){ current_target = 0; current1.quickStop(); Serial.println("------------STOP------------"); } current1.setTargetCurrent(current_target); current1.output(); Serial.print(" "); Serial.println(current1.getCurrent(), DEC); }
シリアルモニタからモータの入力電流を指定してそのときの測定値を出力させるプログラムです.実行した結果は,ハンチングが激しいですが電流値は一応制御できてるっぽいです.やったぜ.
(2016/09/19修正)ADCとDACを書き間違えていたので修正
倒立振子制作日記(そのいち)
はじめに
急に作りたくなったので.
私は一応,大学で機械工学を修め,ロボコンで実践的なロボット製作にも携わったことがある人間ですが,今ではすっかり情報系という半端モンでございます.研究でバーチャル空間にどっぷり漬かっている間にそろそろ機械工学の知識が風化し始めているので,ここいらでちょっと制御工学の復習をしとこうと思ってロボットの製作を思い立ったというわけであります.丁度夏休みだし.
そこで今回は,制御対象として比較的わかりやすい(であろう)部類であり,困ったときの先達がたーっくさんいる倒立振子を課題として,制御コードや回路から作って完成させようというプロジェクトを立ち上げてみました.勉強が目的なので,今後の制作では先達の情報は大いに参考にしつつも,できるだけ正解は見ないように(パクらないように)したいと思います.
部品集め
主にaitendo,秋月電子,千石電商で買いあさりました.それにしてもaitendoはセンサ・IC類がバカ安だな...とはいえ自分で全部揃えると結構かかりました.なるべく安く抑えようと思ったんだけどなー
(注)下表の他に,適当なジャンパ線,ケーブル類,はんだ,ピンヘッダ等が必要です.
品名・型番 | 購入場所 | 価格(円) | 用途 |
---|---|---|---|
びんぼうでいいの | aitendo | 999 | 制御マイコン |
DCジャック | aitendo | 30 | 制御マイコン |
Arduino用ピンヘッダ | aitendo | 95 | 制御マイコン |
ATD6050 3軸加速度/ジャイロモジュール | aitendo | 450 | センサ |
ATD712-M 高感度電流センサモジュール | aitendo | 750 * 2 | センサ |
Arduino用ユニバーサル基板 | aitendo | 200 | 基板実装 |
電池ボックス単3x4本 | 秋月電子 | 60 | 電源 |
9V電池スナップ-DCプラグ変換アダプタ | 千石電商 | 100 | 電源 |
L298N フルブリッジドライバ | 秋月電子 | 350 | モータードライバ |
ブレッドボード | 千石電商 | 270 | 回路テスト |
MYU-004 50mmタイヤセット(3mm六角シャフト用) | 千石電商 | 270 | 機体 |
タミヤ 70097 ツインモーターギヤーボックス | 千石電商 | 790 | 機体 |
タミヤ 70157 ユニバーサルプレート2枚セット | 千石電商 | 570 | 機体 |
合計 | 5,684 |
ちょっと解説
- 「びんぼうでいいの」とは,aitendo独自のArduino互換機です.ブートローダーが書き込まれていないので,Arduinoを持っていない場合は初めにこれを買うのは止した方がいいと思います.後述しますが結構書き込むの面倒だったので・・・
- 今回の倒立振子は,現在の角度のみを(ジャイロ+加速度で)センシングして,その情報から駆動輪の出力を決定するフィードバック制御モデルを構成する予定です.
- ブラシ付DCモータの制御は電流制御入力の方がよいらしい(参考)ので,電流センサをわざわざ購入しました.結構高い・・・実は電流は2~5Ωくらいの抵抗があればオームの法則V=RIで直接計測できるんですが,どうしても抵抗で損失が生じてしまうせいで最大出力が落ちてしまうのが嫌だったので,購入に踏み切りました.実験的に抵抗でもやってみましたが,損失を少なくしようとするとどうしても出力電圧が小さくなります.まあオペアンプ買って電流センサ回路自作しても良かった気がするけど本質じゃないし・・・
- ギヤボックスのギヤ比は58.2:1を選びました.203.7:1はちょっと遅すぎた.
ハードウェア制作
テスト用回路
あとでアップロードします.
困った所
びんぼうでいいのへのブートローダー書き込み
Arduino IDEにはブートローダー書き込み機能がありますが,これを利用するにはブートローダー書き込み済みArduinoなどの書き込み装置が必要です.殆どの場合Arduino UNOを利用すると思います(ネットのTipsでもUNOばかり)が,生憎手元にはArduino Leonardoしかありませんでした.Leonardoの場合はちょっと特殊な操作が必要で,こちらの記事が参考になります.繋ぎ方はこちらの記事の通りにするとよいです.上記事を参考にする場合は,Arduino IDE 1.6.9では全くうまくいかなかったので,旧バージョンの1.0.6を使うと上手くいきました.参考にしてください.
L298Nのつなぎ方
実はデータシートの等価回路図をよく見ると分かるのですが,電流センサ用ピンのPin1とPin15はGNDに繋がないと動きません! なぜかというと,これらはセンサ用と書いてありますが,実はHブリッジ回路のGNDに接続すべき部分になっているためです(センサと書いてあったので私は内臓センサのアナログ出力Pinだと誤解していました・・・).つまりこれらのピンは各モータの電流の通り道そのものであって,センサを使わないからと言って浮かしてしまうとモータに電流が流れなくなってしまいます.従って,使わない時はGNDに接続し,電流センサに接続するときにも一方をGNDに接続する ことを忘れないようにしましょう.(このことに気づくまでに1日かかってしまったことは内緒)
以上,とりあえず文章だけ挙げておきます.余裕があったら追記していきます.
GLSLでOpenCV歪み補正を実装
OpenCVの歪み補正関数はcv::initUndistortRectifyMap()
で予め作成したマッピング画像を使ってcv::remap()
で歪んだカメラ画像を処理する,というものですが,このcv::remap()
関数はOpenGLにやらせることもできます.それもプログラマブルシェーダGLSLを使った並列演算です.つまり,このテクニックを使えばGLSL上で並列化画像処理を実装することができるというわけです.(ここでは画像表示部をOpenGL/GLFWに任せています)
いやOpenCV使えよ
なぜOpenCVをすでに使っているのにわざわざそんなことをするかって?OK,これには理由が2つある.
まず一つ目は,画像処理を並列化することで実行速度の更なる向上が期待できるからだ.OpenCVの関数は既に強力な最適実装がされているものが多いが,それでもまだまだ最適化されていない実装もあるし,それに自分で作った画素アクセスを含む画像処理をもっと高速化するには並列化がおススメの場合が多い.せっかくOpenGLを使って画像表示しているのなら,プログラマブルシェーダを使ってGPUレベルで画像処理させるのが最も効率的なはず.だからGLSLを使うのだ.
もう一つの理由は,このような特殊なGLSLの使い方をすることでGPGPUによる並列演算テクニックの入り口に立つことができるからだ.まあつまり,GPU並列演算の初歩を学ぶという勉強目的ってこと.リアルタイム処理が必要なAR技術では今後必要になるであろう技術なので,勉強がてらOpenCVの機能を肩代わりするのがどういうことなのかを学んでおくことには価値がある.それにまだGLSLのこと良く知らないし.
というわけで,今回はcv::remap()
をOpenGLレイヤーで実装することに挑戦しました.
実装
ソースコード
#pragma once #include <OpenGLHeader.h> #include <Shader.h> #include <opencv2\opencv.hpp> class Remapper { private: GLFWwindow *imgWindow; GLuint vao; GLuint vbo; GLuint imageObj, mapxObj, mapyObj; Shader s; int vertices; // バーテックスシェーダ const char *vertexSource = "#version 330 core \n" "layout(location = 0) in vec4 pv;\n" "void main(void)\n" "{\n" " gl_Position = pv;\n" "}\n"; // フラグメントシェーダ const char *fragmentSource = "#version 330 core \n" "uniform sampler2DRect image;\n" "uniform sampler2DRect map_x;\n" "uniform sampler2DRect map_y;\n" //"uniform vec2 resolution;\n" "layout(location = 0) out vec4 fc;\n" "void main(void)\n" "{\n" " vec4 mapx = texture(map_x, gl_FragCoord.xy);\n" " vec4 mapy = texture(map_y, gl_FragCoord.xy);\n" " fc = texture(image, vec2(mapx.x, mapy.x));\n" "}\n"; public: Remapper() { } Remapper(GLFWwindow *window) { init(window); } void init(GLFWwindow *window) { int w, h; glfwMakeContextCurrent(window); glfwGetWindowSize(window, &w, &h); imgWindow = window; // 頂点配列オブジェクト glGenVertexArrays(1, &vao); glBindVertexArray(vao); // 頂点バッファオブジェクト glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); // [-1, 1] の正方形 static const GLfloat position[][2] = { { -1.0f, -1.0f }, { 1.0f, -1.0f }, { 1.0f, 1.0f }, { -1.0f, 1.0f } }; vertices = sizeof(position) / sizeof(position[0]); glBufferData(GL_ARRAY_BUFFER, sizeof(position), position, GL_STATIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0); glEnableVertexAttribArray(0); // テクスチャ glGenTextures(1, &imageObj); // source image について設定 glBindTexture(GL_TEXTURE_RECTANGLE, imageObj); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGB8, w, h, 0, GL_BGR, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); // map_x について設定 glGenTextures(1, &mapxObj); glBindTexture(GL_TEXTURE_RECTANGLE, mapxObj); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); // map_y について設定 glGenTextures(1, &mapyObj); glBindTexture(GL_TEXTURE_RECTANGLE, mapyObj); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, NULL); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); // シェーダのロード s.initInlineGLSL(vertexSource, fragmentSource); } void remap(cv::Mat src, cv::Mat &dst, cv::Mat map_x, cv::Mat map_y) { glfwMakeContextCurrent(imgWindow); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); s.enable();// シェーダプログラムの使用開始 GLint res[2] = { src.cols, src.rows }; // srcをテクスチャ0に転送する glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, imageObj); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_BGR, GL_UNSIGNED_BYTE, src.data); // map_xをテクスチャ1に転送する glActiveTexture(GL_TEXTURE1); glBindTexture(GL_TEXTURE_RECTANGLE, mapxObj); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_x.data); // map_yをテクスチャ2に転送する glActiveTexture(GL_TEXTURE2); glBindTexture(GL_TEXTURE_RECTANGLE, mapyObj); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_y.data); glUniform1i(glGetUniformLocation(s.program, "image"), 0); // uniform変数"image"にテクスチャユニット0(GL_TEXTURE0)を指定 glUniform1i(glGetUniformLocation(s.program, "map_x"), 1); glUniform1i(glGetUniformLocation(s.program, "map_y"), 2); //glUniform2fv(glGetUniformLocation(s.program, "resolution"), 1, res); glBindVertexArray(vao);// 描画に使う頂点配列オブジェクトの指定 glDrawArrays(GL_TRIANGLE_FAN, 0, vertices);// 図形の描画 glBindVertexArray(0);// 頂点配列オブジェクトの指定解除 s.disable();// シェーダプログラムの使用終了 glBindTexture(GL_TEXTURE_RECTANGLE, 0); // 読み込み dst = cv::Mat(src.size(), CV_8UC3); glReadBuffer(GL_BACK); glReadPixels(0, 0, dst.cols, dst.rows, GL_BGR, GL_UNSIGNED_BYTE, dst.data); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); } ~Remapper() { } };
例によって以前の記事で挙げたShader
クラスを使っています.OpenGLHeader.h
にはOpenGLの他にGLFWとGLEW,GLMが含まれています.使い方は前回のGLImage
クラスとほぼ同じです.Remapper::remap()
のパラメータは順に,ソース画像(8bit,3ch),出力画像(8bit,3ch),マッピング画像(32bit,1ch)2枚,という構成で,ほぼcv::remap()
と同様に使えます.
解説
フラグメントシェーダは,次のように書いてます.
const char *fragmentSource = "#version 330 core \n" "uniform sampler2DRect image;\n" "uniform sampler2DRect map_x;\n" "uniform sampler2DRect map_y;\n" //"uniform vec2 resolution;\n" "layout(location = 0) out vec4 fc;\n" "void main(void)\n" "{\n" " vec4 mapx = texture(map_x, gl_FragCoord.xy);\n" " vec4 mapy = texture(map_y, gl_FragCoord.xy);\n" " fc = texture(image, vec2(mapx.x, mapy.x));\n" "}\n";
やってることは簡単で,map_x
とmap_y
の画素値には浮動小数点で変形先の座標値が格納されているので,texture()
で現在座標gl_FragCoord.xy
の値(=移動先の画素値)を呼び出し,新たに得たリマップ先の座標値の画素値vec2(mapx.x, mapy.x)
を本来の画像テクスチャimage
からサンプルする,という実装になっています.ここのテクスチャサンプラsampler2DRect image
はサンプリング時に自動で線形補間するようにRemapper::init()
内で設定していますので,これだけですべての画素について滑らかに変形できます.
ちなみに,わざわざ\n
で改行するのは,コンパイルエラーが起きた場合に行数を把握しやすくするためです.\n
を省略することもできますが,その場合ソースコードを1行で書いたとみなされてしまい,吐き出されるコンパイルエラーも行数が分からなくなってしまいます.
Remapper::remap()
では,一度レンダリングしてからSwapせずに,直接Swap前のバッファにアクセスして画像データを読み込み,cv::Mat
に書き出しています.その後,バッファを全クリアして終了です.つまり,この一連の流れでcv::Mat
型からcv::Mat
型に変換する画像処理をシェーダレベルで実行したことになります.
なお,OpenGLとOpenCVでは画像テクスチャのY軸が逆ですが,ここでは反転処理を書いていません.なぜなら,今回の処理ではOpenGLに描かせた後にまたOpenCVに渡すので,Y軸が2回反転して結局元に戻るからです.もちろん,反転処理を入れればそれだけ遅くなります.GLSL上で反転させればそんなに速度変わらんかもしれんけど・・・
実装で苦労したところ
なぜかmap_x
とmap_y
テクスチャが転送できないという問題に陥りました.ほぼ前回のGLImage
と同じだから,正しくソースコードが書けているはずなのに・・・
しかし,原因は(やっぱりって感じだけど)glTexImage2D()
及びglTexSubImage2D()
の設定にありました.本来ならば
glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R32F, w, h, 0, GL_RED, GL_FLOAT, NULL); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_RED, GL_FLOAT, map_x.data);
と書くべきところを,
glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_R, w, h, 0, GL_R, GL_FLOAT, NULL); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_R, GL_FLOAT, map_x.data);
とか
glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_LUMINANCE, w, h, 0, GL_LUMINANCE, GL_FLOAT, NULL); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, res[0], res[1], GL_LUMINANCE, GL_FLOAT, map_x.data);
とか書いていたわけです.間違えどころはinternal format(正しいコードでGL_R32F
)とformat(正しいコードでGL_RED
)の値の組み合わせで,推奨される正しい組み合わせはOpenGLのリファレンスに書いてあります.GL_R
とかGL_LUMINANCE
とかはここには使えないくせに,こんなGL_RED
とほぼ同じ意味のグローバル定数を別の値として用意しているOpenGLに俺は文句を言いたい.更に言うと,3chの画像でGL_RGB
で2chの画像(某掲示板の事じゃないゾ!)でGL_RG
って書くなら普通1chならGL_R
かなーとか思いこんじゃうでしょ…
くそが!!!!!
ふぅ,すっきりした.
結果
補正前
OpenCVで補正(cv::remap())
OpenGL(GLSL)で補正(Remapper::remap())
おお!ちゃんとできてる!これで並列化した高速画像処理演算ができるように…ん?
OpenCV: 0.014ms / 71.6fps
OpenGL: 0.016ms / 61.1fps
OpenCVの方が圧倒的に速いじゃんッッッ!!!
というわけで,まる2日かけて書いた今回の実装は完全に徒労に終わりました.でもきっと,今回の経験は将来の役に立つでしょう…
参考文献
床井研究室 - GLSL で画像処理 (1) 画像を取り込む
実は今回の記事は↑の丸パクリリファインです.床井先生いつも参考にさせてもらってます.ありがとうございます.
追記(2016/09/07)
この記事によるとどうやらglPixelRead()
がめちゃ遅いらしく,PixelBufferObject(PBO)を活用するともっと高速化できそうです.PBOは4K映像も30FPSいけるくらい速いらしいし,今度試してみよう.
OpenGLでカメラ画像を表示
OpenCVは好きなんだけど標準の描画GUIであるHighGUIはWindowsマシンでは60fps以上出ない(過去記事参照)ので,私は最近では,画像処理だけOpenCVでやらせて描画ウィンドウはGLFWを使ってOpenGLで描画させる,という方法を採用しています.この方が圧倒的に速いだけでなく,簡単に3DモデルをオーバーラップできたりとARとの親和性が良く,非常に便利です.HighGUIの貧弱なトラックバーなんかもOpenGL用GUIライブラリで改良できますし,シェーダ言語GLSLを上手く使えばCUDAやOpenCLなんて使わなくても並列化画像処理まで実装できます(これはちょっと難しいけど).
ただ問題は,OpenGLで画像を扱うのがOpenCVに比べてちょっと難しいという事です.OpenGLはかなりプリミティブなところを触るAPIなので,ウィンドウに画像を描画するだけでも一苦労です.そこで今回は,GLFWのウィンドウに簡単にOpenCVの画像を描画できるクラスを作りました.
OpenCV画像のOpenGL描画用クラス
ソースコード
/******************************************************** OpenGL Image with OpenCV GLFWでOpenCVのcv::Matを背景描画するためのクラス How to Use: 1. メインループに入る前にGLImageを生成 2. 描画したいGLFWwindowを与えてGLImageを初期化 3. メインループ内で次の様に書く(ex. mainWindowの背景にframeImgを描画) // Change Current Window glfwMakeContextCurrent(mainWindow); // Clear Buffer Bits glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Draw Image glImg.draw(frameImg); // <- Only rendering // Clear Depth Bits (so you can overwride CG on frameImg) glClear(GL_DEPTH_BUFFER_BIT); // Draw your CG // End Draw glfwSwapBuffers(mainWindow); Change 20160119: ・コンストラクタで初期化できるようにした ・コメント大幅追加 ・GLSLをインライン化して外部ファイルを不要にした *********************************************************/ #pragma once #pragma comment(lib, "opengl32.lib") #pragma comment(lib, "glew32.lib") #pragma comment(lib, "glfw3.lib") #include <stdio.h> #include <stdlib.h> #include <GL\glew.h> #include <GL/glfw3.h> #include "Shader.h" #include <opencv2\opencv.hpp> class GLImage { private: GLFWwindow *imgWindow; GLuint vao; // 頂点配列オブジェクト GLuint vbo; // 頂点バッファオブジェクト GLuint image; // テクスチャオブジェクト GLuint imageLoc;// オブジェクトの場所 Shader s; // シェーダ // バーテックスシェーダ const char *vertexSource = "#version 330 core \n" "layout(location = 0) in vec4 pv;\n" "void main(void)\n" "{\n" " gl_Position = pv;\n" "}\n"; // フラグメントシェーダ const char *fragmentSource = "#version 330 core \n" "uniform sampler2DRect image;\n" "layout(location = 0) out vec4 fc;\n" "void main(void)\n" "{\n" " fc = texture(image, gl_FragCoord.xy);\n" "}\n"; int vertices; public: GLImage() { } GLImage(GLFWwindow *window) { init(window); } void init(GLFWwindow *window) { int w, h; glfwMakeContextCurrent(window); glfwGetWindowSize(window, &w, &h); imgWindow = window; // 頂点配列オブジェクト glGenVertexArrays(1, &vao); glBindVertexArray(vao); // 頂点バッファオブジェクト glGenBuffers(1, &vbo); glBindBuffer(GL_ARRAY_BUFFER, vbo); // [-1, 1] の正方形 static const GLfloat position[][2] = { { -1.0f, -1.0f }, { 1.0f, -1.0f }, { 1.0f, 1.0f }, { -1.0f, 1.0f } }; vertices = sizeof(position) / sizeof (position[0]); glBufferData(GL_ARRAY_BUFFER, sizeof(position), position, GL_STATIC_DRAW); glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, 0); glEnableVertexAttribArray(0); // テクスチャ glGenTextures(1, &image); glBindTexture(GL_TEXTURE_RECTANGLE, image); glTexImage2D(GL_TEXTURE_RECTANGLE, 0, GL_RGB, w, h, 0, GL_BGR, GL_UNSIGNED_BYTE, NULL); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MAG_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER); glTexParameteri(GL_TEXTURE_RECTANGLE, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER); // シェーダのロード s.initInlineGLSL(vertexSource, fragmentSource); imageLoc = glGetUniformLocation(s.program, "image"); } void draw(cv::Mat frame) { glfwMakeContextCurrent(imgWindow); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 切り出した画像をテクスチャに転送する cv::flip(frame, frame, 0); glBindTexture(GL_TEXTURE_RECTANGLE, image); glTexSubImage2D(GL_TEXTURE_RECTANGLE, 0, 0, 0, frame.cols, frame.rows, GL_BGR, GL_UNSIGNED_BYTE, frame.data); // シェーダプログラムの使用開始 s.enable(); // uniform サンプラの指定 glUniform1i(imageLoc, 0); // テクスチャユニットとテクスチャの指定 glActiveTexture(GL_TEXTURE0); glBindTexture(GL_TEXTURE_RECTANGLE, image); // 描画に使う頂点配列オブジェクトの指定 glBindVertexArray(vao); // 図形の描画 glDrawArrays(GL_TRIANGLE_FAN, 0, vertices); // 頂点配列オブジェクトの指定解除 glBindVertexArray(0); // シェーダプログラムの使用終了 s.disable(); } };
中身の解説
中でやっていることは,インラインのシェーダをコンパイルさせ,その後ビューポートと同じ大きさのポリゴンを用意し,OpenCV画像データをテクスチャとしてポリゴンと一緒にシェーダに転送する,という流れです.注意すべきところは,OpenCVのY軸とOpenGLのY軸は向きが逆だったり,BGR順じゃなくてRGB順だったりと,OpenCVとOpenGLで画像データの扱いが多少異なるということです.このクラスではそこを気にしなくても良いようになっていて,OpenCVのBGR順のカラー画像をHighGUIと同様にそのまま出力できるようになってます.
なお,この画像描画クラスではOpenGL1.1の固定シェーダ機能を使っておらず,プログラマブルシェーダを利用しています.通常ではWindows環境はOpenGL1.1の機能しか使えないように制約しているので,GLFWとOpenGL, OpenCVの他に,その制約を取っ払うためのライブラリであるGLEWも使用しています.WindowsでもモダンなOpenGLをプログラミングできるようにするためのライブラリにはglewとgl3wという2つのライブラリがあるようですが,どちらを使っても構わないと思います.
また,ここで用いているShader.h
というのは私が作ったシェーダコンパイル用クラスです.中身はこちら.
/******************************************************** Shader Class GLSLシェーダを管理するライブラリ 参考: Modern OpenGL Tutorial http://www.opengl-tutorial.org/ 床井研究室 http://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20090827 Change 20160119: シェーダのソースコード文字列を直接読む仕様を追加 *********************************************************/ #pragma once #include "OpenGLHeader.h" class Shader { protected: GLuint vertexShader, fragmentShader; // シェーダオブジェクト const char *vertexFileName, *fragmentFileName; // シェーダファイル名 void readShaderCompile(GLuint shader, const char *file); // .shaderのコンパイル void readInlineShaderCompile(GLuint shader, const char *source); // シェーダのインラインソースをコンパイル void link(GLuint prog); // コンパイルしたshaderをリンクする public: GLuint program; // シェーダプログラム Shader(); // コンストラクタ ~Shader(); // デストラクタ Shader &operator=(Shader &_s) // コピーコンストラクタ { initGLSL(_s.vertexFileName, _s.fragmentFileName); return *this; } // 初期化 // フラグメントシェーダーの有無で分ける void initGLSL(const char *vertexFile); void initGLSL(const char *vertexFile, const char *fragmentFile); void initInlineGLSL(const char *vertexSource, const char *fragmentSource); // 有効化 void enable(){ glUseProgram(program); } void disable(){ glUseProgram(0); } };
/******************************************************** Shader Class GLSLシェーダを管理するライブラリ 参考: Modern OpenGL Tutorial http://www.opengl-tutorial.org/ 床井研究室 http://marina.sys.wakayama-u.ac.jp/~tokoi/?date=20090827 Change 20160119: シェーダのソースコード文字列を直接読む仕様を追加 *********************************************************/ #include "Shader.h" Shader::Shader() { } Shader::~Shader() { } void Shader::readShaderCompile(GLuint shader, const char *file) { FILE *fp; char *buf; GLsizei size, len; GLint compiled; // ファイルを開く fopen_s(&fp, file, "rb"); if (!fp) printf("ファイルを開くことができません %s\n", file); //ファイルの末尾に移動し現在位置を得る fseek(fp, 0, SEEK_END); size = ftell(fp);//ファイルサイズを取得 // ファイルサイズのメモリを確保 buf = (GLchar *)malloc(size); if (buf == NULL){ printf("メモリが確保できませんでした \n"); } // ファイルを先頭から読み込む fseek(fp, 0, SEEK_SET); fread(buf, 1, size, fp); //シェーダオブジェクトにプログラムをセット glShaderSource(shader, 1, (const GLchar **)&buf, &size); //シェーダ読み込み領域の解放 free(buf); fclose(fp); // シェーダのコンパイル glCompileShader(shader); glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); if (compiled == GL_FALSE){ printf("コンパイルできませんでした!!: %s \n ", file); glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &size); if (size > 0){ buf = (char *)malloc(size); glGetShaderInfoLog(shader, size, &len, buf); printf(buf); free(buf); } } } void Shader::readInlineShaderCompile(GLuint shader, const char *source) { char *infolog; GLsizei size, infolen; GLint compiled; //printf(source); size = strlen(source); // シェーダオブジェクトにプログラムをセット glShaderSource(shader, 1, (const GLchar**)&source, &size); // シェーダのコンパイル glCompileShader(shader); glGetShaderiv(shader, GL_COMPILE_STATUS, &compiled); if (compiled == GL_FALSE){ printf("コンパイルできませんでした!!: InlineShader \n "); glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &size); if (size > 0){ infolog = (char *)malloc(size); glGetShaderInfoLog(shader, size, &infolen, infolog); printf(infolog); free(infolog); } } } void Shader::link(GLuint prog) { GLsizei size, len; GLint linked; char *infoLog; glLinkProgram(prog); glGetProgramiv(prog, GL_LINK_STATUS, &linked); if (linked == GL_FALSE){ printf("リンクできませんでした!! \n"); glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &size); if (size > 0){ infoLog = (char *)malloc(size); glGetProgramInfoLog(prog, size, &len, infoLog); printf(infoLog); free(infoLog); } } } void Shader::initGLSL(const char *vertexFile) { // GPU,OpenGL情報 printf("VENDOR= %s \n", glGetString(GL_VENDOR)); printf("GPU= %s \n", glGetString(GL_RENDERER)); printf("OpenGL= %s \n", glGetString(GL_VERSION)); printf("GLSL= %s \n", glGetString(GL_SHADING_LANGUAGE_VERSION)); //シェーダーオブジェクトの作成 vertexShader = glCreateShader(GL_VERTEX_SHADER); //シェーダーの読み込みとコンパイル readShaderCompile(vertexShader, vertexFile); // シェーダプログラムの作成 program = glCreateProgram(); // シェーダオブジェクトをシェーダプログラムに関連付ける glAttachShader(program, vertexShader); // シェーダオブジェクトの削除 glDeleteShader(vertexShader); // シェーダプログラムのリンク link(program); } void Shader::initGLSL(const char *vertexFile, const char *fragmentFile) { //glewの初期化 GLenum err = glewInit(); if (err != GLEW_OK){ printf("Error: %s\n", glewGetErrorString(err)); } memcpy(&vertexFileName, &vertexFile, sizeof(vertexFile) / sizeof(char)); memcpy(&fragmentFileName, &fragmentFile, sizeof(fragmentFile) / sizeof(char)); // GPU,OpenGL情報 printf("VENDOR= %s \n", glGetString(GL_VENDOR)); printf("GPU= %s \n", glGetString(GL_RENDERER)); printf("OpenGL= %s \n", glGetString(GL_VERSION)); printf("GLSL= %s \n", glGetString(GL_SHADING_LANGUAGE_VERSION)); //シェーダーオブジェクトの作成 vertexShader = glCreateShader(GL_VERTEX_SHADER); fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); //シェーダーの読み込みとコンパイル readShaderCompile(vertexShader, vertexFile); readShaderCompile(fragmentShader, fragmentFile); // プログラムオブジェクトの作成 program = glCreateProgram(); // シェーダオブジェクトをシェーダプログラムに関連付ける glAttachShader(program, vertexShader); glAttachShader(program, fragmentShader); // シェーダオブジェクトの削除 glDeleteShader(vertexShader); glDeleteShader(fragmentShader); // シェーダプログラムのリンク link(program); } void Shader::initInlineGLSL(const char *vertexSource, const char *fragmentSource) { //glewの初期化 GLenum err = glewInit(); if (err != GLEW_OK){ printf("Error: %s\n", glewGetErrorString(err)); } // 擬似的にシェーダファイル名を作成 char vertexFile[] = "InlineVertex", fragmentFile[] = "InlineFragment"; memcpy(&vertexFileName, &vertexFile, sizeof(vertexFile) / sizeof(char)); memcpy(&fragmentFileName, &fragmentFile, sizeof(fragmentFile) / sizeof(char)); // GPU,OpenGL情報 printf("VENDOR= %s \n", glGetString(GL_VENDOR)); printf("GPU= %s \n", glGetString(GL_RENDERER)); printf("OpenGL= %s \n", glGetString(GL_VERSION)); printf("GLSL= %s \n", glGetString(GL_SHADING_LANGUAGE_VERSION)); //シェーダーオブジェクトの作成 vertexShader = glCreateShader(GL_VERTEX_SHADER); fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); //シェーダーの読み込みとコンパイル readInlineShaderCompile(vertexShader, vertexSource); readInlineShaderCompile(fragmentShader, fragmentSource); // プログラムオブジェクトの作成 program = glCreateProgram(); // シェーダオブジェクトをシェーダプログラムに関連付ける glAttachShader(program, vertexShader); glAttachShader(program, fragmentShader); // シェーダオブジェクトの削除 glDeleteShader(vertexShader); glDeleteShader(fragmentShader); // シェーダプログラムのリンク link(program); }
使用方法
コメント文に書いてある通りですが,使用する疑似コードは次の通りです.疑似コードなのでこのままコピペしても動きません(笑).
#include "GLImage.h" GLImage glImg; int main(void) { setup(); //mainWindowの用意 // Initialize GLImage and set context glImg.init(mainWindow); cv::VideoCapture cap(0); while(1){ // get frame Image cv::Mat frameImg; cap >> frameImg; // Change Current Window glfwMakeContextCurrent(mainWindow); // Clear Buffer Bits glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // Draw Image glImg.draw(frameImg); // <- Only rendering // Clear Depth Bits (so you can overwride CG on frameImg) glClear(GL_DEPTH_BUFFER_BIT); // Draw your CG draw(); // End Draw glfwSwapBuffers(mainWindow); } }
大事なところは,ARでCGを描画する場合はglImg.draw()
の直後にglClear(GL_DEPTH_BUFFER_BIT)
を書き加えてデプスバッファを消去してやる,という事です.GLImageはビューポートと同じ大きさのポリゴンにテクスチャを貼りつけるという作業をするため,glImg.draw()
を実行するとデプスバッファが平面に上書きされてしまいます.GLImageで描いた画像を背景画像にするには,デプスバッファを一旦消去してからCGを描画して,最後にglfwSwapBuffers()
を実行します.
出力結果
こんな感じです(めちゃめちゃ暗い…).画像ではGUIも書き込まれていますが,カメラ画像をこのように映すことができます.なお,GUIにはimgui(ocornut/imguiの方)を使用しています.
デルタ型3Dプリンタキット「Zonestar D810」を組み立てた(そのさん)
もはやプリントできている状態なので組み立てたと言うタイトルはふさわしくない気もするのですが…それにしても自宅に3Dプリンタがある生活っていいですよね!動きを眺めているだけで休日が終わってしまった(笑)
さて,今回はZonestar D810で任意のSTLデータをプリントするのにチャレンジします.3Dプリンタに詳しくない人も読んでるかもしれないので一応説明しておくと,3Dプリンタは3Dモデルをそのまま読み込んで出力するわけではありません.(おそらく)どの造形(or切削)方式であれ,3DモデルのSTLデータをプリントするのに具体的にどうプリンタを動かすか(例えばヘッドの軌跡)を命令するためのファイル形式であるGコードを生成する過程が必要です.STLデータからGコードを生成するソフトウェアを3Dプリンタ界隈ではスライサーと呼んでいます.いかに3Dプリンタの性能が良くても,このスライサーの性能如何で仕上がりが全く異なってしまいます.
そこで今回は,前回までにテスト用のGコードからプリントできることを確認したので,D810用にスライサーの設定を変えながら試してみることにします.
今回のテスト用STLデータは…
前回割れたこいつ(キャリッジパーツ)です(笑)
ホストアプリケーション
今回は安定性のため,オフラインでSDカード内のGコードからプリントすることにします.PC-プリンタ間の接続は行いません.なので,今回のタスクはGコードを出力して保存するところまでです.
今回は3Dプリンタ用ホストアプリケーションとしてRepetier-Hostを使いました.こいつはスライサーにSlic3rを利用しています.
D810のファイル内に付属のKISSlicerというのもありましたが,インターフェースが古臭い以外にも,何回か試してみたところあんまりスライサーの性能が良くなさそうだったのでやめました(Macに対応してないし).もう一つ推奨っぽいやつにCura(元々はUltimaker2専用アプリ)というのもありましたが,こちらは最新バージョンだと細かい設定のいじり方が良くわからなくなってたのでボツ.性能がいいと評判のUltimaker2のスライサーだから使ってみたかったんだけど…
あ,ちなみに環境はMac OSX EI Capitanです.多分Winでも設定方法は同じだと思う.
アプリケーションの設定
直ぐにプリントできるわけではなく,最初に起動した時には色々と細かい設定が必要です.キャプチャ画像は既に設定済みですが,最初はSTLデータもなければプリントエリアの形状も円柱状ではありません.まずは右上の歯車"Printer settings"をクリック.
出てくるポップアップウィンドウがこちら.DimensionタブでPrinter typeを画像のように"Delta Printer with Cylindric Print Shape"を選択.こうすることでアプリケーションのプリントエリアが円柱状になります.次にDiameter, HeightをD810の公称値のΦ200×300に設定します.
次がBehaviourタブで基本的な動作設定.今回はDefault Extruder TemperatureをPLAにしてはちょっと高めの210℃に設定し,あとはデフォルトのままにしておきました.これにてOK.
Slic3r(スライサー)の設定
その後,メインウィンドウのSlicerタブでSlic3r - Configurationボタンをクリックすると,Slic3rの細かい設定ができます.これからする設定は多いですが重要です.
まずLayer heightです.これはZ軸精度を表すのですが,最初のレイヤーだけは厚みを別に設定できます.これが便利で,何しろデルタ型3Dプリンタだとその構造上ビルドプレートに水平に動いてくれるとは保証できないわけで,少し初期レイヤーに厚みを持たせることである程度は水平誤差を吸収してくれます.今回は初期レイヤーは0.3mm,その後のレイヤーは0.1mm(D810公称最小値)としました.その他は初期値のまま.
次にInfill.ソリッドの中身をどう埋めるかを規定します.今回はプリンタ部品に使うので,密度は多めに40% (dencity = 0.4),Fill patternはhoneycomb(蜂の巣),Top/bottom fill patternはconcentricに設定しました.ハニカム構造は同じ平面なら最も線の長さ(=フィラメントの量)が短い構造と言われています.concentricは渦巻き状に平面を埋めていく設定といえばわかると思います.
Sppedは…デフォルトのままにしました.プリント速度が気になるようだったら変えようと思います.
Skirt and brimは,初期レイヤーの作り方のオプションです.
Skirtは初期レイヤーの周囲に一度ループを描く設定です.いきなり本体のプリントを始めるとホットエンドを温める段階で出てきたフィラメントが邪魔になったりして,初期レイヤーがうまくプレートにくっつかずに剥がれてしまうことが本当によくあります.そこでSkirtを描いてやることで,これを軽減したり異常を早く察知してプリントに入る前に中止できたりします.1周分じゃ不安だったのでLoops = 2とし,Distance from object = 8mmとして,デフォルト設定よりも少し離してやりました.
Brimは初期レイヤーのみプリント物を周囲に大きく描く設定で,プリント物の周囲にピラピラしたものを付け足します(名前的にはこっちの方がスカートっぽい…).こうすることでプリント物とビルドプレートの接触面積を大きくでき,よりプリント中に剥がれにくくなることを期待できます.様子を見るためにBrim width = 3mmにしました.
Support materialではサポート材の造形方法,Raft(捨てる初期レイヤー)の設定をします.今回はフィラメント節約のため両方使わないので設定しません.Notes以降の設定はD810には関係ないと思うので未設定です.
ここまでできたら,設定を保存しましょう.設定は上のタブ毎に保存する必要があります.設定を変更すると(modified)と出てくるので,横の保存ボタンをクリックします.
保存用ポップアップで設定名をリネームしてOK.
次に隣タブのFilament Settingに移ります.Diameterはフィラメント直径です.D810の場合1.75mmを使います.デフォルト設定は3mmの方になっているので注意しましょう.Temparatureは初期レイヤーとその他のレイヤーで別々に設定できますが,同じにしておいた方がいいです.初期レイヤーをプリントし終えた直後にその場に留まって加熱or冷却し始めるので,その間にどんどんフィラメントが垂れてきてそこだけこんもりとフィラメントが盛り上がり,プリント失敗の原因になります(実体験).
Cooling設定はそのままでもいいと思います.後,ここで次のタブに移る前にちゃんと設定を保存しておきましょう.
Printer Settingタブでは,プリンタ自体の設定を決定します.Print centerは(x,y)=(0,0)にしました.Bed sizeが直交座標になっていますが,無視しましょう.ちゃんと円柱状に設定できてますので.
FirmwareはD810の場合Zonestarのオリジナルですが,Marlinベースと考えていいでしょう.RepRap (Marlin/Sprinter)を選択してうまく動いてくれています.
次のCustom G-codeは結構特殊な設定です.開始時と終了時に付け加えるGコードを直接編集できます.D810の推奨設定として,開始時GコードのG28(ホームポジションに戻る)の直後にG29(オートレベリング)を加える,というものがあります.こうすると開始時にオートレベリングを実行してからプリントを始めるので,プリント実行前に自分で水平調整をしなくて良くなります.
最後はExtruder 1です.Nossle diameterはD810の場合0.4mmです.別のノズルをつけた場合はそれに合わせてください.Positionは複数のExtruderがある場合の話なので無視します.
Retractionはフィラメントの退避設定です.飛び地に移動する際,Lengthで設定した分だけフィラメントを巻き戻してやります.デフォルト設定だと退避しなさすぎて移動時にもフィラメントがこぼれてしまい,プリント後に大変なことになったりプリント失敗したりします.私の場合,画像の設定でうまくいきました.
最後に,忘れずに設定を保存しておきましょう.
元の画面に戻って,Print Settingsなどの空白の設定欄に先ほど保存した設定を選択して入れておきます.
以上の設定が完了したら,スライサーの準備は完了です.STLファイルを読み込んでプリントしたいモデルを配置したら,Slice with Slic3rでGコードを生成します.
しばらくすると,自動的にこの画面に遷移します.下のログに色々と情報が出てきます.例えば,この3Dモデルの生成には10m程度のフィラメントが必要みたいです.このGコードを保存し,SDカードに移してやれば後はプリントするだけです.
プリンタ本体のトラブル
ホットエンドからフィラメントが溢れ出てしまう
高確率でノズルのネジが緩んでいます.そんな時は一旦フィラメントを抜いてホットエンド内を掃除しましょう.掃除は樹脂が溶ける温度までホットエンドを温めたまま作業しますので,火傷の危険があります.注意して作業しましょう.
まずホットエンドを温め,ホットエンドやノズルの周りの溶けた樹脂をドライバーか何かでこそぎ落とします.次にノズルをペンチを使って外し,ネジの部分にこびりついた樹脂もこそぎ落とします.ノズルは非常に熱いです.
その後,フィラメント挿入口からパーツクリーナーやシリコンスプレーを吹き付け,ホットエンド内を洗います.最後にノズルをしっかりと緩まないように取り付け,ノズルから十分な量が出てくるまでフィラメントを挿入します.これでOKだと思います.
なお,この現象が起きる原因はフィラメントの射出不良です.例えばZ軸キャリブレーションがうまくいっておらず,初期レイヤー成型時にノズルがビルドプレートにぴったりくっついてしまってフィラメントが十分押し出せない時間が長く続く,というような場合にこのような現象が起きやすいです.今後起きないようにするためには,Z軸キャリブレーションをしっかりと行うことが重要です.
すぐ初期レイヤーが剥がれる
主にZ軸キャリブレーションができていないことが原因です.何度もZ軸キャリブレーションを繰り返して,再現性が得られてからプリントを始めると良いでしょう.
あと,機械的な理由もあります.D810のオートレベリングはキャリッジに結構負担がかかるので,キャリッジが弱いとバネのようになってうまく水平出しができないです.その状態だと,オートレベリングしたのに初期レイヤーの厚みが場所によって違うなんてことになり,最悪の場合ビルドプレートをガリっと削ってしまうことになります.この場合,初期レイヤーの厚みをちょっと厚めにしておくと良いと思います.
ヒートベッドを使う場合,温度設定を60〜80℃くらいの間で色々と試してみるといいと思います.実は最初組み立てている時には,ヒートベッドだけ独立で別電源とか頭悪い設計だなーと思ってたのですが,いざ使ってみるとGコードを変えずにヒートベッドの温度を独立に変えられるという仕組みはなかなかに使い心地が良かったりします.でも電源プラグはメインボードと統一して欲しかった…その他の制御系が上にある関係上困難なのはわかるけどさぁ…
結果
15回くらい失敗してようやくうまくいきました.それでもちょっと底の部分が反り上がってしまいましたが,これでもキャリッジとしては機能するでしょう.まあ,そもそも壊れたキャリッジを使った3Dプリンタでプリントしてるのでプリント性能はこれが限度でしょう.
ゴミを取り除くとこんな感じです.なかなかいいんじゃないでしょうか.