しめ鯖日記

swift, iPhoneアプリ開発, ruby on rails等のTipsや入門記事書いてます

GPUImageでカメラの画像にリアルタイムでフィルターをかける

GPUImageというライブラリを使って、カメラ映像にリアルタイムでフィルターをかける事を試してみました。

github.com

まずはCocoaPodsでGPUImageをインストールします。

target 'MyApp' do
  use_frameworks!

  pod 'GPUImage'
end

次はInfo.plistのNSCameraUsageDescriptionキーに、カメラ使用理由を追加します。

f:id:llcc:20170927144842p:plain

続けて画面にカメラの映像を表示してみます。
適当なViewControllerを以下のように修正します。

import UIKit
import GPUImage

class ViewController: UIViewController {
    let camera = GPUImageVideoCamera(sessionPreset: AVCaptureSession.Preset.vga640x480.rawValue, cameraPosition: .back)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        camera?.outputImageOrientation = .portrait
        let imageView = GPUImageView(frame: view.bounds)
        view.addSubview(imageView)
        
        camera?.addTarget(imageView)
        camera?.startCapture()
    }
}

f:id:llcc:20170927145102p:plain

カメラの映像を撮っているのはGPUImageVideoCameraというクラスです。
このクラスは画面サイズやどのカメラを使うかなどの情報を渡して初期化します。

let camera = GPUImageVideoCamera(sessionPreset: AVCaptureSession.Preset.vga640x480.rawValue, cameraPosition: .back)

カメラの映像を画面に表示しているのが下の処理です。
GPUImageViewというビューを画面に貼り付け、それをカメラに紐付ける事で、カメラ映像を画面に表示しています。

let imageView = GPUImageView(frame: view.bounds)
view.addSubview(imageView)
camera?.addTarget(imageView)

続けて映像にリアルタイムフィルターを適用してみます。
先程の処理を下のように置き換えます。

class ViewController: UIViewController {
    let camera = GPUImageVideoCamera(sessionPreset: AVCaptureSession.Preset.vga640x480.rawValue, cameraPosition: .back)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        camera?.outputImageOrientation = .portrait
        let imageView = GPUImageView(frame: view.bounds)
        view.addSubview(imageView)
        
        let filter = GPUImageiOSBlurFilter()
        camera?.addTarget(filter)
        filter.addTarget(imageView)
        camera?.startCapture()
    }
}

今回の修正箇所は下の部分です。
フィルタークラス(GPUImageiOSBlurFilter)を生成して、それをカメラとImageViewと紐付けています。

let filter = GPUImageiOSBlurFilter()
camera?.addTarget(filter)
filter.addTarget(imageView)

下が実行時の映像です。
分かりにくいのですが、カメラ映像にぼかし処理がかかっています。

f:id:llcc:20170927150100g:plain

AVAudioEngineでmp3をエフェクト付きで再生する

AVAudioEngineというクラスを使って、mp3をエフェクト付きで再生してみました。

まずはプロジェクトにmp3ファイルを追加します。

f:id:llcc:20170926162616p:plain

追加したファイルがBuild Phasesにも入っているか確認します。
入っていない場合は+ボタンからmp3を追加します。

f:id:llcc:20170926162629p:plain

まずは普通にmp3を再生します。
下のようにAVAudioEngineとAVAudioPlayerNodeのインスタンスを作成、それらをconnectする事で再生します。

import UIKit
import AVFoundation

class ViewController: UIViewController {
    let engine = AVAudioEngine()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let player = AVAudioPlayerNode()
        if let path = Bundle.main.path(forResource: "music", ofType: "mp3") {
            let url = URL(fileURLWithPath: path)
            
            if let file = try? AVAudioFile(forReading: url) {
                engine.attach(player)
                engine.connect(player, to: engine.mainMixerNode, format: file.processingFormat)
                player.scheduleFile(file, at: nil, completionHandler: nil)
                try? engine.start()
                player.play()
            }
        }
    }
}

もしmp3を再生するだけならAVAudioPlayerを使う方が簡単そうでした。

www.cl9.info

続けてエフェクトをかけてみます。
ViewControllerを以下のように書き換えます。
下コードでは再生速度を2倍にしています。

class ViewController: UIViewController {
    let engine = AVAudioEngine()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let player = AVAudioPlayerNode()
        if let path = Bundle.main.path(forResource: "music", ofType: "mp3") {
            let url = URL(fileURLWithPath: path)
            
            if let file = try? AVAudioFile(forReading: url) {
                engine.attach(player)
                
                let unit = AVAudioUnitVarispeed()
                unit.rate = 2.0
                engine.attach(unit)
                engine.connect(player, to: unit, format: file.processingFormat)
                engine.connect(unit, to: engine.mainMixerNode, format: file.processingFormat)
                
                player.scheduleFile(file, at: nil, completionHandler: nil)
                try? engine.start()
                player.play()
            }
        }
    }
}

今回の変更点は下の箇所です。
AVAudioUnitVarispeedという再生スピード変更ノードを作成してそれをconnectしています。

engine.attach(player)

let unit = AVAudioUnitVarispeed()
unit.rate = 2.0
engine.attach(unit)
engine.connect(player, to: unit, format: file.processingFormat)
engine.connect(unit, to: engine.mainMixerNode, format: file.processingFormat)

続けて違うエフェクトも使ってみます。
次はAVAudioUnitTimePitchを試しました。

AVAudioUnitTimePitchは音程と速度を調整するユニットで、音の高低を変更したり音程そのままで速度アップしたりできます。

let unit = AVAudioUnitTimePitch()
unit.pitch = 1200.0
engine.attach(unit)
engine.connect(player, to: unit, format: file.processingFormat)
engine.connect(unit, to: engine.mainMixerNode, format: file.processingFormat)

ファイルへの書き込みは下のように行います。
installTapメソッドでbufferを取得できるので、それを順番にファイルに書き込んでいきます。

if let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
        let settings = [
            AVFormatIDKey: kAudioFormatMPEG4AAC,
            AVSampleRateKey: 44100.0,
            AVNumberOfChannelsKey: 2 ] as [String : Any]
        if let file = try? AVAudioFile(forWriting: url.appendingPathComponent("test.aac"), settings: settings) {
            unit.installTap(onBus: 0, bufferSize: 4096, format: unit.inputFormat(forBus: 0), block: { buffer, when in
            try? file.write(from: buffer)
        })
    }
}

録音を停止したい場合はremoveTapメソッドを利用します。

unit.removeTap(onBus: 0)

Phones.frameworkで端末の写真の取得・保存・削除

Phones.frameworkを使って、端末の写真の取得・保存・削除を試してみました。

写真を操作する前にInfo.plistにNSPhotoLibraryUsageDescriptionに写真を操作する理由を記述します。
これがないとアプリがクラッシュします。

f:id:llcc:20170925143607p:plain

取得

端末写真をUIImageとして取得する処理は下の通りです。
PHAsset.fetchAssetsでPHAssetを取得、PHImageManagerと取得したPHAssetを使ってUIImageを読み込んでいます。

import UIKit
import Photos

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let options = PHFetchOptions()
        let result = PHAsset.fetchAssets(with: .image, options: options)
        if let asset = result.firstObject {
            PHImageManager.default().requestImage(for: asset, targetSize: CGSize(width: asset.pixelWidth, height: asset.pixelHeight), contentMode: .aspectFill, options: nil, resultHandler: { image, options in
                
                let imageView = UIImageView(image: image)
                imageView.frame.origin.x = (self.view.frame.width - imageView.frame.width) / 2
                self.view.addSubview(imageView)
            })
        }
    }
}

起動すると写真が表示されていることが分かります。

f:id:llcc:20170925144344p:plain

fetchAssetsの戻り値は下のようなメソッドを持っています。

let result = PHAsset.fetchAssets(with: .image, options: PHFetchOptions())
result.count // → 件数
result.object(at: 0) // → 0番目の要素(PHAsset)
result.objects(at: [0, 1, 2]) // → 0, 1, 2番目の要素の配列
result.countOfAssets(with: .audio) // → audioアセットの数を取得

取得の際はソート・NSPredicateを使った絞り込み・取得件数の指定も可能です。

let options = PHFetchOptions()
options.sortDescriptors = [
    NSSortDescriptor(key: "creationDate", ascending: false)
]
options.predicate = NSPredicate(format: "pixelWidth < 2000")
options.fetchLimit = 1
let result = PHAsset.fetchAssets(with: .image, options: options)

削除

削除処理は下の通りです。
PHAssetChangeRequestのdeleteAssetsメソッドにPHAssetを渡す事で削除します。

if !asset.canPerform(.delete) {
    return
}

PHPhotoLibrary.shared().performChanges({
    PHAssetChangeRequest.deleteAssets(NSArray(array: [asset]))
}, completionHandler: { _, _ in
    // 削除後の処理
})

削除の前に、自動で下のダイアログが表示されます。

f:id:llcc:20170925145353p:plain

PHAssetChangeRequestのdeleteAssetsですが、performChangesメソッドを使わずに呼び出すと下のようなエラーになります。

// 単独で呼ぶと下のようなエラー
PHAssetChangeRequest.deleteAssets(NSArray(array: [asset]))

// 'NSInternalInconsistencyException', reason: 'This method can only be called from inside of -[PHPhotoLibrary performChanges:completionHandler:] or -[PHPhotoLibrary performChangesAndWait:error:]'

保存

保存は下の通りです。
PHAssetChangeRequestのcreationRequestForAssetにUIImageを渡す事で端末への保存ができます。

let image = UIImage()

PHPhotoLibrary.shared().performChanges({
    PHAssetChangeRequest.creationRequestForAsset(from: image)
}, completionHandler: { _, _ in
    // 作成後の処理
})

【iOS】ユーザーがスクリーンショットを撮ったタイミングを検知する

スクリーンショットを撮ったタイミングを検知するには.UIApplicationUserDidTakeScreenshotを使います。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        
        NotificationCenter.default.addObserver(self, selector: #selector(self.userDidTakeScreenshot), name: .UIApplicationUserDidTakeScreenshot, object: nil)
    }
    
    @objc func userDidTakeScreenshot() {
        print(#function)
    }
}

スクリーンショットを撮ったら登録したメソッドが呼ばれました。

f:id:llcc:20171016140412p:plain

アプリがバックグラウンドにある時も試したのですが、バックグラウンド時は登録したメソッドは呼ばれないようでした。

モバイル版GoogleAnalyticsでFirebaseを選ばない方法

最近はGoogleAnalyticsプロパティー作成時にモバイルアプリを選ぶとFirebase一択になりました。
今日はモバイルアプリでもFirebaseなしのGoogleAnalyticsを使う方法を書いてみます。

f:id:llcc:20170923150323p:plain

まずはウェブサイトとしてプロパティーを作成します。

f:id:llcc:20170923150519p:plain

続けてそのプロパティーを選択してビュー追加画面に移動します。

f:id:llcc:20170923150615p:plain

そこではFirebaseを使わないモバイルアプリを選べるので、選択して作成します。

f:id:llcc:20170923150727p:plain

これでFirebaseなしのGoogleAnalyticsを使う事ができます。

参考URL

support.google.com

iOS11 & Xcode9 で UINavigationController の pushViewController が斜めになる問題対策

push遷移アニメーションが下のように斜めになってしまったので対策を調べてみました。

f:id:llcc:20170922131817g:plain

stackoverflowによると、遷移先のviewDidLoadでtableViewのcontentInsetAdjustmentBehaviorを.neverにすれば良いとのことでした。
popViewControllerの時のアニメーションは遷移元のcontentInsetAdjustmentBehaviorによるので、遷移先・遷移元の両方でセットすると良いかと思います。

if #available(iOS 11.0, *) {
    tableView.contentInsetAdjustmentBehavior = .never
}

1つ1つのtableViewに設定するのが大変な時は、appearanceで設定すれば一括変更されます。

if #available(iOS 11.0, *) {
     UITableView.appearance().contentInsetAdjustmentBehavior = .never
}

それとこちらの現象はUITableViewStyleがgroupedでもplainでも発生するようです。

f:id:llcc:20170922132607g:plain

Xcode9で新規作成したアプリではこの現象は発生しませんでした。
比較したところ、adjustedContentInsetというreadonlyプロパティーの値が違っていました。
今回は調べきれなかったのですが、adjustedContentInsetの値を変更すればcontentInsetAdjustmentBehaviorを使わずに対応できるかもしれません。

print(tableView.adjustedContentInset) // → Xcode9で作成したアプリはtopに64が入ってた、Xcode8以前で作成したアプリのtopは0だった

2017/10/13追記

下の方法ですが、UITableViewがタブバーやツールバーと被ってしまうという問題があるようです。
なので下方法を使う場合はUITableViewのtableFooterViewに何か置くなどの対処が必要になりそうです。

if #available(iOS 11.0, *) {
     UITableView.appearance().contentInsetAdjustmentBehavior = .never
}

f:id:llcc:20171013143110p:plain

それと新規アプリがpushViewControllerで斜めにならないのはUINavigationBarのisTranslucentをtrueにしていたからでした。
この値をfalseにするとpushViewControllerが斜めになってしまうようです。

UINavigationBar.appearance().isTranslucent = false

参考URL

stackoverflow.com

2017/10/30追記

TableViewがタブバーと被る問題、UITableViewControllerに下コードを足すことでも解消できました。

override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    if #available(iOS 11.0, *) {
        tableView.contentInset.bottom = tabBarController?.tabBar.frame.height ?? 0
    }
}

参考URL

iOS11 + Xcode9.0でedgesForExtendedLayoutの値を空にしていると、UITableViewのドリルダウンでアニメーションが崩れる

【Swift4】形態素解析をやってみる | NSLinguisticTagger

NSLinguisticTaggerというクラスで形態素解析ができるようなので試してみました。

import UIKit

let text = "I have a beautiful pen."
let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemes(forLanguage: "en"), options: 0)

tagger.string = text
tagger.enumerateTags(in: NSRange(location: 0, length: text.characters.count), scheme: .tokenType, options: []) { tag, tokenRange, sentenceRange, _ in
    
    let subString = (text as NSString).substring(with: tokenRange)
    print("\(subString) : \(tag?.rawValue ?? "")")
}

文字列を渡した所、下のように分解してくれました。

f:id:llcc:20170921142717p:plain

WhiteSpaceは引数のオプションに.omitWhitespaceを渡すことで除外できます。

let text = "I have a pen."
let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemes(forLanguage: "en"), options: 0)

tagger.string = text
tagger.enumerateTags(in: NSRange(location: 0, length: text.characters.count), scheme: .lexicalClass, options: [.omitWhitespace]) { tag, tokenRange, sentenceRange, _ in
    
    let subString = (text as NSString).substring(with: tokenRange)
    print("\(subString) : \(tag?.rawValue ?? "")")
}

f:id:llcc:20170921144430p:plain

日本語の分解も可能です。

let text = "関東機械学習協会員"
let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemes(forLanguage: "en"), options: 0)

tagger.string = text
tagger.enumerateTags(in: NSRange(location: 0, length: text.characters.count), scheme: .tokenType, options: []) { tag, tokenRange, sentenceRange, _ in
    
    let subString = (text as NSString).substring(with: tokenRange)
    print("\(subString) : \(tag?.rawValue ?? "")")
}

漢字が並んでいる場合やひらがなが並んでいる場合もうまく取得できました。

f:id:llcc:20170921143620p:plain

f:id:llcc:20170921143634p:plain

enumerateTagsの引数のscemeにlexicalClassを渡すと品詞分解してくれます。

let text = "I have a beautiful pen."
let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemes(forLanguage: "en"), options: 0)

tagger.string = text
tagger.enumerateTags(in: NSRange(location: 0, length: text.characters.count), scheme: .lexicalClass, options: []) { tag, tokenRange, sentenceRange, _ in
    
    let subString = (text as NSString).substring(with: tokenRange)
    print("\(subString) : \(tag?.rawValue ?? "")")
}

f:id:llcc:20170921142913p:plain

品詞分解は日本語非対応です。

f:id:llcc:20170921143802p:plain

言語がどのスキーマに対応しているかどうかはavailableTagSchemesを使うと確認できます。

print(NSLinguisticTagger.availableTagSchemes(forLanguage: "en").map { $0.rawValue })
print(NSLinguisticTagger.availableTagSchemes(forLanguage: "ja").map { $0.rawValue })

f:id:llcc:20170921143917p:plain

languageを渡すと言語を判定してくれます。

let text = "I have a beautiful pen."
let tagger = NSLinguisticTagger(tagSchemes: NSLinguisticTagger.availableTagSchemes(forLanguage: "en"), options: 0)

tagger.string = text
tagger.enumerateTags(in: NSRange(location: 0, length: text.characters.count), scheme: .language, options: []) { tag, tokenRange, sentenceRange, _ in
    
    let subString = (text as NSString).substring(with: tokenRange)
    print("\(subString) : \(tag?.rawValue ?? "")")
}

f:id:llcc:20170921143148p:plain

日本語、日本語と英語ミックスでも正しく判定できました。

f:id:llcc:20170921143203p:plain

f:id:llcc:20170921143218p:plain