2017年3月9日木曜日

モーションセンサー、うまくいくようないかないような



モーションセンサーには以下のようなものがある。
値の範囲は決まっているはずだが、センサーの精度の問題なのか、範囲の上限/下限値にならないことも多く、上限/下限値をまたいだ処理をさせると値が飛んでしまうこともあり困る。
プロはどうやってスムーズな処理をしてるんだろう? 誰かおせーて!!
  • 加速度センサー(Accelerometer)
    • X軸、Y軸、Z軸方向に動かした場合、どちらの方向にどれだけ加速度がかかったか
  • ジャイロセンサー(Gyro)
    • X軸、Y軸、Z軸を軸として傾けた場合の、1秒あたりに傾いたラジアン角を得られる(角速度)
  • 磁気センサー(Magnetometer)
    • X軸、Y軸、Z軸の磁力センサーの値
  • デバイスモーション(DeviceMotion)
    • 加速度、ジャイロ、磁気の3センサーを統合して加工。ということはこれだけ使えばいい?
    • 公式には「装置の姿勢、回転速度、および加速度のカプセル化された測定値」とある。
    • 加速度計は、重力加速度とユーザー加速度の2つの加速度ベクトルの合計を測定します。 ユーザー加速度は、ユーザーがデバイスに与える加速度です。 Core Motionは、ジャイロスコープと加速度計の両方を使用してデバイスの姿勢を追跡することができるため、重力とユーザーの加速度を区別できます。 CMDeviceMotionオブジェクトは、重力加速度およびユーザー加速度プロパティの両方の測定値を提供します。
    • 姿勢センサー(Attitude)
      • X軸、Y軸、Z軸を軸として傾けた場合の角度(Pitch、Roll、Yawのオイラー角)、行列(マトリックス)、クォータニオン(よくわからんが)を得られる。
      • X軸中心の前後の傾きがpitch
        • 画面上で寝かせて0度。起こしていくと増えていき、垂直で90度。さらに画面下向きに寝かせていくと減っていき0度に。
        • 増えていった値が90度を境に減るはずだが、時には87度前後で減り出したり、よくわからない。
      • Y軸中心の左右の回転がroll
        • pitchが90度を超えると急に-150度とかになったり、わからない
      • Z軸中心の左右の傾きがyaw
        • これがわずかに傾いてるだけでもpitchの値が90までいかないうちに減り出すことがあるようだ。
  • 重力加速度(Gravity)
    • 地球の重力に対しての向き
    • X軸はLandscapeでホームボタンが右なら-1.0、左なら1.0、Portrateなら0
    • Y軸はPortrateでホームボタンが下なら-1.0、上なら1.0、Landscapeなら0
    • Z軸は画面が真上なら-1.0、垂直に立てると0、画面が真下なら1.0

ライブラリはあらかじめCoreMotionをimportしとく。
import CoreMotion //モーションセンサー

モーションマネージャーはインスタンス変数として作っておかないと、ARCですぐに消されちゃうので注意。
var motionMgr:CMMotionManager?

センサーの設定とスタートまで

//モーションマネージャーは各種センサー共通
motionMgr = CMMotionManager()
//更新頻度(秒)
motionMgr?.deviceMotionUpdateInterval = 0.1 //姿勢センサー
//姿勢センサースタート(クロージャでセンサーが更新された時の処理を書く)
motionMgr?.startDeviceMotionUpdates(to: OperationQueue.current!, withHandler: { (data, error) in
        let pitchDeg = round(180 * data!.attitude.pitch / M_PI)
        let rollDeg = round(180 * data!.attitude.roll / M_PI)
        let yawDeg = round(180 * data!.attitude.yaw / M_PI)

})

//姿勢センサーの停止
motionMgr?.stopDeviceMotionUpdates()

ローパスフィルター

センサーの値がノイズによってばらけることが多いらしく、前回検出した値との加算平均を取る処理(これをローパスフィルターとかハイパスフィルターとか言うらしい)をした方がいいそうである。
最新の値の90%と、前回の値の10%を足すわけですな。


//ローパスフィルター処理(ここでは0.9がレート)

var outputValue:Double
if (self.prevAngle != nil) {
        outputValue = (pitchDeg * 0.9) + (self.prevAngle! * (1.0 - 0.9))
    } else {
        outputValue = pitchDeg
    }
self.prevAngle = outputValue

他のセンサー使用時

センサーの値を取り出す各種プロパティは省略。


//各センサーの更新頻度(秒)設定
motionMgr?.accelerometerUpdateInterval = 0.1 //加速度センサー
motionMgr?.gyroUpdateInterval = 0.1 //ジャイロセンサー

//各センサーのスタート(引数は省略したが姿勢センサーと同様に)

motionMgr?.startAccelerometerUpdates() //加速度センサー

motionMgr?.startGyroUpdates() //ジャイロセンサー



//各センサー停止

motionMgr?.stopAccelerometerUpdates() //加速度センサー

motionMgr?.stopGyroUpdates() //ジャイロセンサー

参考サイト

2017年3月8日水曜日

画像の合成、初歩の初歩

ある画像の上に別の画像を合成したい場合の初歩的な覚書。

UIGraphicsのImageContextを使う。これは要するにオフスクリーン描画領域。画面に表示する前の画像を扱うために確保するメモリ領域だな。
メモリ領域と言ってもwidthとheightのsizeで大きさを指定して作る。

使い方は難しくないけどちょっと変わっていて、Contextを作る
UIGraphicsBeginImageContext(CGSize)

UIGraphicsEndImageContext()
で挟まれた間に処理を書くことになる。

作られたContextは明確な変数に入るわけじゃないようだが、イメージに対してのdrawメソッドや、レイヤーに対してのrenderメソッドなどを書くとContextに対して描画が行われる。
例)
let img = UIImage(named: "ufoImage")
img?.draw(in: CGRect(origin: CGPoint.zero, size: view.frame.size))

上記はimgの画像Contextに描画するというものであり、img描画するわけじゃないからね。

いくつかUIImageを用意して順番にContextにdrawしてやれば、重ねられた合成画像が得られるわけだ。
いろいろ処理した後の画像は
UIGraphicsGetImageFromCurrentImageContext()
関数で得ることができる。(これは引数なし)

実際のコード

//画像の準備
let backImg = UIImage(named: "backImage"//背景画像
let ufoImg = UIImage(named: "ufoImage"//上に合成する画像
//オフスクリーンのContext作る
UIGraphicsBeginImageContext(view.frame.size)
//背景をContextに描画
backImage?.draw(in: CGRect(origin: CGPoint.zero, size: view.frame.size))
//合成する画像を位置を指定して描画
ufoImg?.draw(in: CGRect(x: view.frame.midX, y: view.frame.midY, width: 50, height: 50))
//context上に合成された画像を得る
let compositedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

Contextは多用途

このContextを使うのは当然画像合成だけじゃなくいろいろ使える。
UIImageとCGImageなんかを取っ替え引っ替えいじってると画像の傾きがおかしくなったりすることもあるんだけど、そういう時にUIImageやCGImageなどの仕様上の呪縛(制約)から解放した画像を作り、あらためてUIImageを作り直してやるとかにも使える。

でかいサイズの画像を小さいサイズのContextに描画してやるとサイズ縮小ができたりとか。

まだよくわかってないことも多いので、いずれあらためて書くかも。

2017年3月1日水曜日

AVCapturePhotoOutputで少し難しくカメラアプリを作る

カメラで撮影するアプリを作るには UIImagePickerController を使うのが一番楽なんだけど、これだと細かいことができないので、 AVCapturePhotoOutput を使った書き方をしてみる。
iOS9までは別のクラス使ってたんだけど、iOS10からは非推奨になったので、これを使わにゃいかんのだ。

必要なFrameworkはAVFoundation。
以下に全コードを載せる。
撮影ボタンをUIButtonじゃなくて上部のツールバーにしてるけど、まあいいっしょ?
コードをシンプルにして構造をわかりやすくするため、極力エラー処理は省いてある。


カメラの使用とフォトライブラリへのアクセスをするので、あらかじめInfo.plistにそれに関するプライバシーの警告テキストを入力しておくこと。んでないと落ちる。
AVCaptureSessionクラス に入出力の情報をいろいろ設定し、startRunning() でカメラのライブビュー(プレビュー)表示を開始。ライブビュー映像はLayerを指定しないといけないようなので、imageViewにLayerを追加して表示している。
撮影ボタンを押すと takePhoto() が呼ばれ撮影。
撮影された画像はdelegateメソッドである capture(〜)メソッド の引数として受け取れるので、そこでDataからUIImageに変換。
それを普通に UIImageWriteToSavedPhotosAlbum()メソッド で写真ライブラリに保存している。

シミュレータじゃカメラ機能が使えないからiPadの実機で試したんだけど、撮影時のflashModeの行をコメントアウトしないと落ちてしまう。
iPadにはストロボないけど、.autoにしとけば自動で判断してくれそうなものだが。

コード

import UIKit
import AVFoundation

//キャプチャの入出力管理クラス
var captureSession:AVCaptureSession?
//静止画データの出力用
var stillImageOutput:AVCapturePhotoOutput?
//キャプチャ画像のプレビュー用
var videoPreviewLayer:AVCaptureVideoPreviewLayer?
//プレビュー表示用
var imageView = UIImageView()

class ViewController: UIViewController, AVCapturePhotoCaptureDelegate {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        //ツールバーに撮影ボタン追加
        let toolBar = UIToolbar(frame: CGRect(x: 0, y: 10, width: view.bounds.size.width, height: 44))
        let takePhotoButton = UIBarButtonItem(title: "撮影", style: .plain, target: self, action: #selector(ViewController.takePhoto))
        toolBar.items = [takePhotoButton]
        view.addSubview(toolBar)

        //プレビューに使うimageViewを作成
        imageView = UIImageView(frame: CGRect(x: 0, y: toolBar.frame.size.height, width: view.bounds.size.width, height: view.bounds.size.height))
        self.view.addSubview(imageView)

        
        //カメラの画像をキャプチャするクラスを作る
        captureSession = AVCaptureSession()
        //撮影解像度設定
        captureSession?.sessionPreset = AVCaptureSessionPresetPhoto
        
        //キャプチャーデバイスをビデオカメラに設定
        let device = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo)
        //ビデオを入力する。try!なのでエラー処理をせず、入力エラーの場合クラッシュ
        let input = try! AVCaptureDeviceInput(device: device)
        //キャプチャー管理のクラスに入力情報追加
        captureSession?.addInput(input)
        //キャプチャー管理のクラスに出力情報追加
        stillImageOutput = AVCapturePhotoOutput()
        captureSession?.addOutput(stillImageOutput)
        
        
        //プレビューレイヤー作成
        videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        //プレビューレイヤーのframe設定
        videoPreviewLayer?.frame = imageView.bounds
        //UIImageViewContentModeに相当
        videoPreviewLayer?.videoGravity = AVLayerVideoGravityResizeAspectFill
        //Layerbounds部分以外をマスクする
        videoPreviewLayer?.masksToBounds = true
        //imageViewlayerとしてプレビューレイヤーを乗せる
        imageView.layer.addSublayer(videoPreviewLayer!)

        
        //セッション開始
        captureSession?.startRunning()
        
    }
    
    func takePhoto() {
        let settingsForMonitoring = AVCapturePhotoSettings()
        //カメラのストロボモード
//        settingsForMonitoring.flashMode = .auto //コメントにしないと落ちる
        //カメラの手ぶれ補正
        settingsForMonitoring.isAutoStillImageStabilizationEnabled = true
        //最高解像度で撮影するか否か
        settingsForMonitoring.isHighResolutionPhotoEnabled = false
        //画面キャプチャ処理
        //撮影された画像はdelegateメソッドで処理する
        stillImageOutput?.capturePhoto(with: settingsForMonitoring, delegate: self)
    }
    
    //AVCapturePhotoCaptureDelegateメソッド
    func capture(_ captureOutput: AVCapturePhotoOutput, didFinishProcessingPhotoSampleBuffer photoSampleBuffer: CMSampleBuffer?, previewPhotoSampleBuffer: CMSampleBuffer?, resolvedSettings: AVCaptureResolvedPhotoSettings, bracketSettings: AVCaptureBracketedStillImageSettings?, error: Error?) {
        //結果がnilじゃなければ
        if let photoSampleBuffer = photoSampleBuffer {
            //キャプチャされたphotoSampleBufferの画像をJPEGとしてDataに直す
            //previewPhotoはサムネイル画像らしい
            let photoData = AVCapturePhotoOutput.jpegPhotoDataRepresentation(forJPEGSampleBuffer: photoSampleBuffer, previewPhotoSampleBuffer: previewPhotoSampleBuffer)
            //DataUIImageに変換
            let image = UIImage(data: photoData!)
            //写真ライブラリに保存
            UIImageWriteToSavedPhotosAlbum(image!, nil, nil, nil)
        }
    }

}


エラーチェックをする場合

     で挟まれたところを置き換える。

//キャプチャするデバイスをビデオに設定
        guard let device = AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo) else {
            print("デバイスエラー")
            return
        }
        //ビデオを入力する。try!なのでエラー処理をせず、入力エラーの場合クラッシュ
        let input = try! AVCaptureDeviceInput(device: device)
        //追加可能な入力かどうか調べる
        if captureSession!.canAddInput(input) {
            //キャプチャー管理のクラスに入力情報追加
            captureSession?.addInput(input)
            //静止画の出力クラスを設定
            stillImageOutput = AVCapturePhotoOutput()
            //追加可能な出力かどうか調べる
            if captureSession!.canAddOutput(stillImageOutput) {
                //キャプチャー管理のクラスに出力情報追加
                captureSession?.addOutput(stillImageOutput)
                
                //プレビューレイヤー作成
                videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
                //guardは、nilでなければunwrapして返し、nilならそこで終る
                guard let _videoPreviewLayer = videoPreviewLayer else {
                    return
                }
                //プレビューレイヤーのframe設定
                _videoPreviewLayer.frame = imageView.bounds
                //UIImageViewで言うところのContentModeのこと
                _videoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill
                //Layerbounds部分以外をマスクする
                _videoPreviewLayer.masksToBounds = true
                //imageViewlayerとしてプレビューレイヤーを乗せる
                imageView.layer.addSublayer(_videoPreviewLayer)
                
                //セッション開始
                captureSession?.startRunning()
            } else {
                print("出力先エラー")
            }
        } else {
            print("入力元エラー")
        }
参考

2017年2月28日火曜日

CALayer覚え書き

よくわかってないんだけど、UIViewとCALayerの違いなどについて。

CALayerはアニメーション実行のFrameworkであるCoreAnimationが提供する仕組みで、その名の通り画像をアニメーションやお絵かきをさせるのに使う。
データとしてはビットマップグラフィックとして持っている。
レイヤーっつーんだから、透明なお絵かき板みたいな概念のものだろう。
CALayerを直接操作するには、事前にQuartzCore/QuartzCoreのFrameworkのimportが必要。

UIViewはお絵かき、アニメーション以外の部分、たとえばタッチした時のイベント処理とかの機能を提供している。

UIViewのインスタンスを作るとCALayerが自動的に作られるため、通常はUIViewに向けたメソッドやプロパティをいじることで、内包しているCALayerを使ったアニメーションも実現できる。

UIViewに別なViewやButtonなどをaddSubviewするように、CALayerに別なCALayerをaddSublayerすることもできる。
透明なお絵かき板を何枚も重ねて、それぞれが直接干渉しない形で動かしたりするイメージだろうか。

間違ってるかもしれないけど、たぶんだいたいこんな感じじゃないかと思う。
もっと詳しくわかったら追記する。

2017年2月18日土曜日

UIBarButtonItemの画像をきれいに表示する

UIBarButtonItemに画像を使用した場合、実サイズで表示されてしまうため、よほど小さい画像にしてやらない限り大きくはみ出してしまう。
かといって小さい画像にしてやると、Retinaディスプレイでもジャギーが目立つ粗いものになり、見た目がよろしくない。

UIButtonやUIImageViewにはContentModeの設定(Scale To FillとかAspectFitとか)ができ、部品の大きさに合わせて画像を縮小表示してくれるのだが、UIBarbuttonItemにはそんなものはない。

一瞬悩んだが、XcodeのAssetsに2x、3xの画像を登録すればいいことがわかった。


画像の大きさは20*20ピクセルがいいらしく、2xでは40*40、3xでは60*60の画像を登録した。

なお、今更ではあるけど、iOS7から(だっけ?)のフラットデザインの方針により、カラー画像を登録してもモノクロになっちゃうので、白く抜きたいところをalpha値 = 0 で描いた画像を使わなきゃいけない。
やり方によっちゃカラーの画像も使えるんじゃないかと思うんだけど、それはまた必要になったら調べるなり。

2017年2月15日水曜日

円周上の座標を求める

単に円を描くならUIBezierPath()を使ったovalメソッドを使うのが簡単なのだが、円周上の座標を求めて処理をしたい場合には、sin、cosを使った計算が必要になる。

半径:r
角度:θ
の場合、

X座標は r*cosθ
Y座標は r*sinθ
で求められる。

角度θはプログラミング言語上ではラジアン角で指定する必要があるので、その変換を含めて実際のコードは以下のようになる。
(degreeには度数を、rには半径を入れておく)

let θ = M_PI / Double(180) * Double(degree)
let x = Double(r) * cos(θ)
let y = Double(r) * sin(θ)

ラジアン角とは?

ラジアン角:円の半径に等しい長さの弧の中心に対する角度(半径と同じ長さだけ円周を描いた時の角度が1ラジアン)

円周率はπなので、360度は直径*πだから、1ラジアンの2倍*円周率ということで、
360度 = 2πラジアン ということになる。

めんどくさいんだけど、数学的にはこっちの方が自然で、式が簡単に書けるんだって。まあその辺りのところは別に勉強するとして。

度数とラジアンの変換は、

360 = 2π だから、
ラジアン角 = 2π / 360 * 度数 であるので、簡単にして、
ラジアン角 = π / 180 * 度数 ということになるわけだ。