Pythonistaで動画を撮影する

# 概要
今回は「[AVFoundation](https://developer.apple.com/documentation/avfoundation)を使用して、pythonで動画を撮影しフレーム単位で画像をアウトプットする。」ということを実施しました。
簡単ですが、撮影するプロセスをまとめて、実際に動作確認しました。

# 動画を取得する流れ
- セッションの確立
[AVCaptureSession](https://developer.apple.com/documentation/avfoundation/avcapturesession)クラスで、動画や音声をインプットとして取得し、それをアウトプットとして保存、といった一連の作業を行います。

- カメラの使用
[AVCaptureVideoPreviewLayer](https://developer.apple.com/documentation/avfoundation/avcapturevideopreviewlayer)クラスで、カメラで撮影した動画を画面に表示します。

- 動画の取得
[AVCaptureMovieFileOutput](https://developer.apple.com/documentation/avfoundation/avcapturemoviefileoutput)クラスと[AVCaptureVideoDataOutput](https://developer.apple.com/documentation/avfoundation/avcapturevideodataoutput)クラスを使用してカメラから取得した動画を処理します。
最終的に撮影した画像をサーバで解析したいために、今回動画をフレーム単位で扱えるAVCaptureVideoDataOutputを選択しました。
    - AVCaptureMovieFileOutput：取得した動画をmp4などで保存する
    - AVCaptureVideoDataOutput：取得した動画で、フレーム単位のアクセスを可能とする

- フレームの扱い
以下のように、protocolsで[AVCaptureVideoDataOutputSampleBufferDelegate](https://developer.apple.com/documentation/avfoundation/avcapturevideodataoutputsamplebufferdelegate)を指定すると、カメラから取得した動画をサンプルバッファ（今回のケースだと動画のフレーム）として、methodsで指定したメソッドに引き渡します。
methodsは自分でコーディングするので、このメソッドの中でフレーム画像を処理できるようになります。
ですので、別のprotocolsを指定すれば、音声のサンプルをmethodsで処理できるようになるのかなと思います。

```
sampleBufferDelegate = create_obj_class(
                 'sampleBufferDelegate',
                 methods=[captureOutput_didOutputSampleBuffer_fromConnection],
                 protocols='AVCaptureVideoDataOutputSampleBufferDelegate'
```

#環境
- [Pythonista3 v3.2](http://omz-software.com/pythonista/)
- iPad iOS 12.1.4

# ソース
```
from objc_util import *
from ctypes import c_void_p, cast
import ui
import time
import console

# 制御用の処理は 6 フレームに1回(5fps)とする
FRAME_INTERVAL = 6  # 30fps / 6 = 5fps
frame_counter = 0
last_fps_time = time.time()
fps_counter = 0

main_view = None

CIImage = ObjCClass('CIImage')
UIImage = ObjCClass('UIImage')

AVCaptureSession = ObjCClass('AVCaptureSession')
AVCaptureDevice = ObjCClass('AVCaptureDevice')
AVCaptureDeviceInput = ObjCClass('AVCaptureDeviceInput')
AVCaptureVideoDataOutput = ObjCClass('AVCaptureVideoDataOutput')
AVCaptureVideoPreviewLayer = ObjCClass('AVCaptureVideoPreviewLayer')

dispatch_get_current_queue = c.dispatch_get_current_queue
dispatch_get_current_queue.restype = c_void_p

CMSampleBufferGetImageBuffer = c.CMSampleBufferGetImageBuffer
CMSampleBufferGetImageBuffer.argtypes = [c_void_p]
CMSampleBufferGetImageBuffer.restype = c_void_p

CVPixelBufferLockBaseAddress = c.CVPixelBufferLockBaseAddress
CVPixelBufferLockBaseAddress.argtypes = [c_void_p, c_int]
CVPixelBufferLockBaseAddress.restype = None

CVPixelBufferUnlockBaseAddress = c.CVPixelBufferUnlockBaseAddress
CVPixelBufferUnlockBaseAddress.argtypes = [c_void_p, c_int]
CVPixelBufferUnlockBaseAddress.restype = None

debugflg = 1

ciContext = ObjCClass('CIContext')
ctx = ciContext.contextWithOptions_(None)

def write_output(out_ci_img, filename='.output.jpg'):
	ctx = ciContext.contextWithOptions_(None)
	cg_img = ctx.createCGImage_fromRect_(out_ci_img, out_ci_img.extent())
	ui_img = UIImage.imageWithCGImage_(cg_img)
	c.CGImageRelease.argtypes = [c_void_p]
	c.CGImageRelease.restype = None
	c.CGImageRelease(cg_img)
	c.UIImageJPEGRepresentation.argtypes = [c_void_p, CGFloat]
	c.UIImageJPEGRepresentation.restype = c_void_p
	data = ObjCInstance(c.UIImageJPEGRepresentation(ui_img.ptr, 0.75))
	data.writeToFile_atomically_(filename, True)

	return filename

def captureOutput_didOutputSampleBuffer_fromConnection_(_self, _cmd, _output, _sample_buffer, _conn):
    global frame_counter, move, turn
    global fps_counter, last_fps_time, debugflg, ui, main_view
    fps_counter += 1

    now = time.time()

    if int(now) > int(last_fps_time):
        label_fps.text = '{:5.2f} fps'.format((fps_counter) / (now - last_fps_time))
        last_fps_time = now
        fps_counter = 0

    if frame_counter == 0:    	  

        _imageBuffer = CMSampleBufferGetImageBuffer(_sample_buffer)
        CVPixelBufferLockBaseAddress(_imageBuffer, 0)
        ciimage = CIImage.imageWithCVPixelBuffer_(ObjCInstance(_imageBuffer))

        print('ciimage:', ciimage)

        if debugflg == 1:
        	print('image output!')
        	outfile = write_output(ciimage)
        	console.show_image(outfile)

        	debugflg = 0

        CVPixelBufferUnlockBaseAddress(_imageBuffer, 0)

    frame_counter = (frame_counter + 1) % FRAME_INTERVAL

def button_tapped(sender):
	global debugflg
	print('tapped')
	debugflg = 1

sampleBufferDelegate = create_objc_class(
                            'sampleBufferDelegate',
                            methods=[captureOutput_didOutputSampleBuffer_fromConnection_],
                            protocols=['AVCaptureVideoDataOutputSampleBufferDelegate'])

@on_main_thread

def main():
    global main_view, label_status, label_action, label_fps, session, delegate, output

    delegate = sampleBufferDelegate.new()

    frame_w = 375
    frame_h = 550
    main_view = ui.View(frame=(0, 0, frame_w, frame_h))
    main_view.name = 'SampleBuffer'

    session = AVCaptureSession.alloc().init()
    device = AVCaptureDevice.defaultDeviceWithMediaType_('vide')

    _input = AVCaptureDeviceInput.deviceInputWithDevice_error_(device, None)

    if _input:
        session.addInput_(_input)
        print('succesed!')
    else:
        print('Failed to create input')
        return

    output = AVCaptureVideoDataOutput.alloc().init()
    queue = ObjCInstance(dispatch_get_current_queue())
    output.setSampleBufferDelegate_queue_(delegate, queue)
    output.alwaysDiscardsLateVideoFrames = True

    session.addOutput_(output)

    session.sessionPreset = 'AVCaptureSessionPreset640x480'

    prev_layer = AVCaptureVideoPreviewLayer.layerWithSession_(session)
    prev_layer.frame = ObjCInstance(main_view).bounds()
    prev_layer.setVideoGravity_('AVLayerVideoGravityResizeAspectFill')
    ObjCInstance(main_view).layer().addSublayer_(prev_layer)

    button = ui.Button(frame=(0, 0, frame_w, 30), flex='W', name='capture')
    button.background_color = (0, 0, 0, 0.5)
    button.tint_color = 'white'
    button.title = 'capture_start'
    button.action = button_tapped
    button.alignment = ui.ALIGN_CENTER

    main_view.add_subview(button)

    # ビデオデータの実 FPS 表示
    label_fps = ui.Label(frame=(0, 30, frame_w, 30), flex='W', name='fps')
    label_fps.background_color = (0, 0, 0, 0.5)
    label_fps.text_color = 'white'
    label_fps.text = ''
    label_fps.alignment = ui.ALIGN_CENTER

    main_view.add_subview(label_fps)

    session.startRunning()
    main_view.present('sheet')
    main_view.wait_modal()

    session.stopRunning()
    delegate.release()
    session.release()
    output.release()

if __name__ == '__main__':

    main()
```

# 実行結果
アプリ起動後
![image1.png](https://qiita-image-store.s3.amazonaws.com/0/368822/e7f58d88-4cfa-3f9b-b183-a499d836f1d0.png)

アプリ停止後
![image2.png](https://qiita-image-store.s3.amazonaws.com/0/368822/57bbce40-dae2-c5d3-88e6-6e3b6419da98.png)

consoleに画像が表示されましたが、横向きになってしまいしたね。。。

# 解説
captureOutput_didOutputSampleBuffer_fromConnection_で、引数の_imageBufferが動画のフレームとして取得した画像となってます。
試しにどのような画像になっている確かめるためにconsoleでコンソール上に画像を表示しますが、コンソール上で表示するためにuiimageに変換しています。

画像をフレーム単位で取得できたので、ソケット通信等でサーバに送信するといったことができそうです。

※参考
　https://gist.github.com/robo8080/dd1538665994b48972a62966b4369354
　[画像クラスの違い(UIImage vs CGImage vs CIImage)](https://qiita.com/hrichii/items/c77a7cd07a42767985d5)

# 最後に
画像を取得できましたが、現在のソースだとcaptureOutput_didOutputSampleBuffer_fromConnection_の処理が途中で止まってしまう状況です。
※画像を保存しておくキューがいっぱいになると動作しなくなるのかなと思ってますが、まだ原因が分かっていないです。

もし、この辺で詳しい方がいらっしゃいましたらご教授いただけると幸いです。