2012年1月14日 星期六

Filter4Cam 學習之 Paint with Incoming Video

since: 2012/01/11
update: 2012/01/14

reference: Conway's Game of Life Painted with Incoming Video (Core Image Tut) | Indie Ambitions

A. 結合 Core Image 與 Core Graphics
   1. 有一個優秀的 app 叫作 Composite, 它可以讓你將影像的輸入當成畫布來繪畫.
      使用 core image 的濾鏡也可以作到類似的功能.

   2. 在這篇文章將使用影像的輸入當成 cell images 來實作 Conway's Game Of Life
        (康威:生命遊戲).

   3. 並且, 更進一步地. 用來將影像的輸入當成畫布來繪製的處理, 使用到 Core Image
       和 Core Graphics. 我們將會使用 core graphics 來繪製 CGImage, 該 CGImage 能
       被轉換成 Core Image, 並且採用任意的 CIFilters 濾鏡效果來達成.

   4. 這篇文章將接續建置在之前的文章上.
        說明: 記得先將 MainStoryboard.storyboard 中的 buttonslider 設為 Hidden,
                  並移至銀幕的最下方.

---------------------------------------------------------------------------------------------

B. 繪畫功能
   1. 首先, 必需設定好繪畫(drawing)類別. 新增一個 CGContext 到 ViewController 類別,
       並新增一個方法用來設置. ViewController.h 檔案修改如下:
....
@interface ViewController : GLKViewController <AVCaptureVideoDataOutputSampleBufferDelegate>
{
    AVCaptureSession *session;
   
    CIContext *coreImageContext;
    EAGLContext *context;
    GLuint _renderBuffer;
    IBOutlet UISlider *sldr;
    CIImage *ciimg;
   
    //@add
    CGSize screenSize;
    CGContextRef cgcontext;
    CIImage *maskImage;
    float scl;
}

@property (strong, nonatomic) EAGLContext *context;

- (IBAction)snap:(id)sender;

//@add
-(void)setupCGContext;

@end

   2. 在實作檔: ViewController.m, 需要在 viewDidLoad 方法裡, 將螢幕大小存入變數中,
       並且呼叫 setupCGContext 方法.

- (void)viewDidLoad
{
    [super viewDidLoad];
       ....
    //@add
    CGSize scrn = [UIScreen mainScreen].bounds.size;

    //UIScreen mainScreen.bounds returns the device in portrait, we need to switch it to landscape
    screenSize = CGSizeMake(scrn.height, scrn.width);
   
    if ([[UIScreen mainScreen] respondsToSelector:@selector(scale)]) {
        scl = [[UIScreen mainScreen] scale];
        screenSize = CGSizeMake(screenSize.width * scl, screenSize.height * scl);
    }
    [self setupCGContext];
}

    說明: 在這段程式碼區塊中, 檢查 devices 是否為 retina 的. 當使用 openGL 描繪到
               螢幕時是使用 pixels 的, 因此如果是在 retina 的 device 中, 就需要按比例提高
               繪製的圖形大小. 

   3. 接著, 在 ViewController.m 中, 實作 setupCGContext 方法:
//@add
-(void)setupCGContext {
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
   
    NSUInteger bytesPerPixel = 4;
    NSUInteger bytesPerRow = bytesPerPixel * screenSize.width;
    NSUInteger bitsPerComponent = 8;
   
    cgcontext = CGBitmapContextCreate(NULL, screenSize.width, screenSize.height, bitsPerComponent, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast);
   
    CGColorSpaceRelease(colorSpace);
}

   4. 下一步, 需要新增一個方法用來畫出 CGImage, 然後轉成 CIImage.
      a. 在 ViewController.h, 新增方法宣告如下:
....
//@add
-(void)setupCGContext;
-(CIImage *)drawGameOfLife;

@end

      b. 在 ViewController.m, 實作方法如下:
....
//@add
-(CIImage *)drawGameOfLife {
    CGContextSetRGBFillColor(cgcontext, 1, 1, 1, 1);
    CGContextFillEllipseInRect(cgcontext, CGRectMake(0, 0, screenSize.width, screenSize.height));
   
    CGImageRef cgImg = CGBitmapContextCreateImage(cgcontext);
    CIImage *ci = [CIImage imageWithCGImage:cgImg];
    CGImageRelease(cgImg);
    return ci;
}
....

    說明: 之後會再修改此方法來畫出 Game Of Life 的狀況. 目前只畫出一個大橢圓.
               這方法的最後四行: 先從 context 建立一個 CGImage, 再從  CGImage 產生
               Core Image 的影像, 最後釋放掉 CGImage 並回傳 Core Image. 依此步驟,
               再套用濾鏡.

   5. 現在, 我們已經設置好建立 CIImage 的方法了, 必需去呼叫它並且使用 CIImage 的
       濾鏡功能. 也要去調整輸入影像的大小來符合所建立的 CIImage, 修改
       ViewController.m 裡的 captureOutput 方法如下:
//@update for new
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
   
    CVPixelBufferRef pixelBuffer = (CVPixelBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
   
    CIImage *image = [CIImage imageWithCVPixelBuffer:pixelBuffer];
   
    float heightSc = screenSize.height/(float)CVPixelBufferGetHeight(pixelBuffer);
    float widthSc = screenSize.width/(float)CVPixelBufferGetWidth(pixelBuffer);
   
    CGAffineTransform transform = CGAffineTransformMakeScale(widthSc, heightSc);
   
    image = [CIFilter filterWithName:@"CIAffineTransform" keysAndValues:kCIInputImageKey, image, @"inputTransform", [NSValue valueWithCGAffineTransform:transform],nil].outputImage;
   
    maskImage = [self drawGameOfLife];
   
    image = [CIFilter filterWithName:@"CIMinimumCompositing" keysAndValues:kCIInputImageKey, image, kCIInputBackgroundImageKey, maskImage, nil].outputImage;
   
    [coreImageContext drawImage:image atPoint:CGPointZero fromRect:[image extent] ];
   
    [self.context presentRenderbuffer:GL_RENDERBUFFER];
}

---------------------------------------------------------------------------------------------

C. 利用繪製的 CGImage 對輸入影像作濾鏡處理
   1. 第一個要套用到圖像(輸入的影像)的濾鏡是 CIAffineTransform(2D 仿射變換),
       它會放大或縮小影像來符合螢幕的尺寸.

   2. 第二個濾鏡為 CIMinimumCompositing(最低合成), 它使用我們建的圖像
        (黑底橢圓白)並且對照到其大小. 從 pixel 的角度看來, 這個濾鏡使用二個值之一
        的最小顏色值. 因為黑色是絕對的最小值, 而白色是絕對的最大值, 因此我們會得到
        有著黑色橢圓圍繞的外側以及從影格(video frame)傳來的顏色.

   3. 編譯並執行: (左右相反, 上下顛倒)

---------------------------------------------------------------------------------------------

D. 引進生命遊戲
   1. 開始來引進生命遊戲的模型. 在這邊不去涉及到實作, 基礎上採用 Alan Quatermain
      建立的版本, 並新增一些額外的屬性: 一個隨機的種子程序用來產生部分的 cell; 以及
      一個用來新增特定樣式(爬行動物)到 grid 的方法.

   2. 先下載此篇文章作者的專案原始碼 並解壓縮. 將 GOLModel.hGOLModel.m 加到
       專案裡.

   3. 在 ViewController.h 中引入 GOLModel.h 並建立一個 Game Of Life 的實體變數.
....
//@add
#import "GOLModel.h"

@interface ViewController : GLKViewController <AVCaptureVideoDataOutputSampleBufferDelegate>
{
.... 
    //@add
    GOLModel *GOL;
}

   4. 在 ViewController.mviewDidLoad 方法中作初始化.
- (void)viewDidLoad
{
    [super viewDidLoad];
    ....
    //@add
    GOL = [[GOLModel alloc] initGameWidth:30 andHeight:20];
    [GOL randomPopulate];
}

   5. 目前已經有了初始化的 GOL(Game Of Life) 物件, 需要改變 ViewController.m
       的 drawGameOfLife 方法來顯現出遊戲的狀態.

-(CIImage *)drawGameOfLife {
    //@add
    // 步驟 1: 經由將螢幕的大小除以 cells 的數目, 來取得每個 cell 的寬與高.
    int colwidth = screenSize.width / GOL.width;
    int rowheight = screenSize.height / GOL.height;

    //CGContextSetRGBFillColor(cgcontext, 1, 1, 1, 1);
    //@update
    // 步驟 2: 接著, 使用不透明度(opacity)為 0.4 來清除螢幕, 這會讓 cells 在完全消失後,
    //              還能產生幾個回合. 在視覺上較為有趣.
    CGContextSetRGBFillColor(cgcontext, 0, 0, 0, 0.4);
    CGContextFillEllipseInRect(cgcontext, CGRectMake(0, 0, screenSize.width, screenSize.height));

    //@add
    NSArray *ar = GOL.cells;
   
    // 步驟 3: 反覆地從 GOL 模型中, 計算每個 cell 的起始 x 與 y 的座標.
    for (int i = 0; i < [ar count]; i++) {
        BOOL CELLACTIVE = [[ar objectAtIndex:i] boolValue];
        int x = i % GOL.width;
        int y = (int)(i/GOL.width);

        // 步驟 4: 接著, 如果發現到一個活躍(active)的 cell, 就在適當的位置畫一個白圈. 
        //               +1 並使用寬與高; -2 來讓 cells 間留一些縫隙.
        if (CELLACTIVE) {
            CGContextSetRGBFillColor(cgcontext, 1, 1, 1, 1);
            CGContextFillEllipseInRect(cgcontext, CGRectMake(x * colwidth + 1, y * rowheight + 1, colwidth - 2, rowheight - 2));
        }
    }

        // 步驟 5: 如同之前的, 將 CGImage 轉成 CIImage.
        CGImageRef cgImg = CGBitmapContextCreateImage(cgcontext);
        CIImage *ci = [CIImage imageWithCGImage:cgImg];
        CGImageRelease(cgImg);
        return ci;
}

   6. 編譯並執行: (左右相反, 上下顛倒)
       產生隨機的一群小圓圈, 取代之前的一個大圓圈. 並在小圓圈中可以看到影像.

---------------------------------------------------------------------------------------------

E. 進行生命遊戲
   1. 現在開始讓遊戲進行. 新增一個 buttonMainStoryboard.storyboard 用來啟動
       與關閉遊戲. 給此 button 一個 IBOutlet: playGOLButton, 以及方法: playGOL.
       並做好相關的聯結. 我們也需要一個計時器: 新增一個實體變數:
       NSTimer *refreshTimer.

   2. 在 ViewController.h 中修改如下:
....
@interface ViewController : GLKViewController <AVCaptureVideoDataOutputSampleBufferDelegate>
{
....
    //@add
    GOLModel *GOL;
   
    //@add
    IBOutlet UIButton *playGOLButton;
    NSTimer *refreshTimer;
}

....
//@add
-(void)setupCGContext;
-(CIImage *)drawGameOfLife;

//@add
- (IBAction)playGOL:(id)sender;
-(void)updateGOL:(NSTimer *)timer;

@end

   3. 在 ViewController.m 中新增方法如下:

//@add
// 更改 buttons 的標籤, 接著新增計時器給 updateGOL 方法使用或關閉計時器.
- (IBAction)playGOL:(id)sender {
    if (refreshTimer) {
        [playGOLButton setTitle:@"Play" forState:UIControlStateNormal];
        [playGOLButton setTitle:@"Play" forState:UIControlStateHighlighted];
        [refreshTimer invalidate];
        refreshTimer = nil;
       
    } else {
        [playGOLButton setTitle:@"Stop" forState:UIControlStateNormal];
        [playGOLButton setTitle:@"Stop" forState:UIControlStateHighlighted];
       
        // 效能提升: 每 0.5 秒執行一次 drawGameOfLife, 取代每個 frame 執行.
        refreshTimer = [NSTimer scheduledTimerWithTimeInterval:0.5 target:self selector:@selector(updateGOL:) userInfo:nil repeats:YES];
    }
}

//@add
// 對 GOL 物件呼叫 update 方法, 將會使遊戲進行一個步驟. 然後在更新的 GOL
// 模型上重新繪製 maskImage.
-(void)updateGOL:(NSTimer *)timer {
    [GOL update];
    maskImage = [self drawGameOfLife];
}

   4. 在  ViewController.m 裡的 captureOutput: 方法中, 修改如下: 
-(void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    ....
    //@update
    // 這邊呼叫了一次相同的 code, 可以將它移除(作註解)
    //maskImage = [self drawGameOfLife];
    ....
}

   5. 在 ViewController.m 裡的 viewDidLoad 方法最下方, 加入以下的 code, 不然會
       得到黑色的銀幕直到按下 Play button.
- (void)viewDidLoad
{
    [super viewDidLoad];
    ....

    //@add
    maskImage = [self drawGameOfLife];
}

   6. 編譯並執行:


---------------------------------------------------------------------------------------------

F. 更改生命遊戲裡 Cell 的值
   1. 下一件我們想作的事是: 在生命遊戲模型中增加多點觸控的互動機制.
       當我們觸控銀幕時, 對於每個觸控, 我們都要去計算 cell 在 GOL 中的位置
       並且開啓或關閉.

   2. 首先, 在 ViewController.m 裡的  viewDidLoad 方法中, 開啓 view 的多點觸控的功能.
- (void)viewDidLoad
{
    [super viewDidLoad];
    //@add
    [self.view setMultipleTouchEnabled:YES];
    ....
}

   3. 接著, 在 ViewController.m 中, 新增 touchesBegantouchesMoved 方法.
        (它們是非常相似的)
//@add
-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *t in touches) {
        CGPoint loc = [t locationInView:self.view];
        loc = CGPointMake(loc.x * scl, loc.y * scl);
        loc = CGPointMake(loc.x, screenSize.height - loc.y);
       
        int colWidth = (int)screenSize.width / GOL.width;
        int rowHeight = (int)screenSize.height / GOL.height;
       
        int x = floor(loc.x / (float)colWidth);
        int y = floor(loc.y / (float)rowHeight);
       
        if (refreshTimer) {
            [GOL spawnWalkerAtCellX:x andY:y];
        } else {
            [GOL toggleCellX:x andY:y];
        }
    }
    maskImage = [self drawGameOfLife];
}

//@add
-(void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
    for (UITouch *t in touches) {
       
        CGPoint loc = [t locationInView:self.view];
        loc = CGPointMake(loc.x * scl, loc.y * scl);
        loc = CGPointMake(loc.x, screenSize.height - loc.y);
       
        int colWidth = screenSize.width / GOL.width;
        int rowHeight = screenSize.height / GOL.height;
       
        int x = floor(loc.x / colWidth);
        int y = floor(loc.y / rowHeight);
       
        [GOL toggleCellX:x andY:y];
    }
    maskImage = [self drawGameOfLife];
}

    說明: 首先, 反覆地從 touches set 中, 取得每一個 touch.  對於每一個 touch, 我們先
               轉換觸控點的位置, 因為 view 使用一種座標系統, 而 Core Graphics 使用垂直
               轉換的系統.   

               一旦完成後, 使用銀幕的位置以及對於 cells 尺寸的瞭解來計算:
               在 GOL 模型中, 被觸控的 cell 之座標. 接著呼叫 GOL 的 -toggleCellX:andY:
               方法來開啓或關閉.

               最後, 當我們完成了對所有 touch 各自 cell 的轉換, 用 Core Graphics 來更新
               GOL 的繪製, 並回傳 CIImage 給連串的 CIFilter 使用.

               唯一的不同點是: 在 touchesBegan 方法中, 假如遊戲目前是啓動的, 採用呼叫
                spawnWalker 方法, 以取代切換個別的 cell 方式. 呼叫 spawnWalker 方法會
               在觸控點的位置建立特定的生命遊戲圖案. 這個持續性的圖案會移動越過銀幕
               直到碰見另一組啓用的 cell.

   4. 編譯並執行:
        你可以經由觸控來操縱生命遊戲, 不論遊戲是啓動的或停止的.

---------------------------------------------------------------------------------------------

G. 一些非必要的調整
   1. 為這個 app 加上一些非必要的視覺強化. 首先, 使用 perlin noise generator
       (由 Ken Perlin 發明的自然噪聲生成算法) 來決定 cell 的顏色.

   2. 先將原作者 專案原始碼 裡的 CZGPerlinGenerator.hCZGPerlinGenerator.m
       加到專案裡, 並調整 ViewController.h 如下:

//@add
#import "CZGPerlinGenerator.h"

@interface ViewController : GLKViewController <AVCaptureVideoDataOutputSampleBufferDelegate>
{
....
    //@add
    CZGPerlinGenerator *perlin;
}

.....

   3. 接著, 調整 ViewController.m 裡的 viewDidLoad 方法如下:

- (void)viewDidLoad
{
    [super viewDidLoad];
    ....
    [self setupCGContext];
   
    //@add
    perlin = [[CZGPerlinGenerator alloc] init];
    perlin.octaves = 1;
    perlin.zoom = 50;
    perlin.persistence = 0.5;  
   
    //@add
    GOL = [[GOLModel alloc] initGameWidth:30 andHeight:20];
    [GOL randomPopulate];
   
    //@add
    maskImage = [self drawGameOfLife];
}

   4. 最後, 調整 ViewController.m 裡的 drawGameOfLife 方法如下:

-(CIImage *)drawGameOfLife {
....
    for (int i = 0; i < [ar count]; i++) {
        BOOL CELLACTIVE = [[ar objectAtIndex:i] boolValue];
        int x = i % GOL.width;
        int y = (int)(i/GOL.width);
        // step 4: We next draw a white circle at the appropriate position if we’ve found an active cell. We add +1 and use a width and height – 2 to leave a littel padding between cells.
        if (CELLACTIVE) {
            //@add
            float r = [perlin perlinNoiseX:x * 5 y:y * 5 z:100 t:0] + .5;
            float g = [perlin perlinNoiseX:x * 5 y:y * 5 z:0 t:100] + .5;
            float b = [perlin perlinNoiseX:x * 5 y:y * 5 z:0 t:0] + .5;
           
            //CGContextSetRGBFillColor(cgcontext, 1, 1, 1, 1);
            CGContextSetRGBFillColor(cgcontext, r, g, b, 1);
            CGContextFillEllipseInRect(cgcontext, CGRectMake(x * colwidth + 1, y * rowheight + 1, colwidth - 2, rowheight - 2));
        }
    }
....
}

   5. Perlin noise 是一個隨機的數字產生器, 創造出逼真的外觀紋理.  如果你在 Photoshop
       中使用 Clouds 濾鏡, 會得到 Perlin noise 的二維紋理.

   6. 編譯並執行:
      你的 cells 會讓你覺得: 你是經由不同顏色的手機看出去的.

---------------------------------------------------------------------------------------------

H. 效能
   1. 最後, 討論一下效能的問題. 這個 app 在 iPhone 4S 跟 iPad 2 上執行得相當平順.
       如果是在 3GS 上執行的話, 應該會蠻不順的.

   2. 有一些調整可以來增進效能. 你可以將 CGContext 從 RGB 改成 greyscale(灰階)
       (不過, 就無法使用 perlin noise 的 code), 這有一點幫助. 你也可以降低 GOL 中
       cells 的數目.

   3. 降低影像的解析度也有幫助. 將輸入的影像從 720p 改成 640×480, 並且改變
        cgcontext 跟 core graphics 的 code 以使它畫出 640×480 的影像. 然後移動
        魚鱗狀的濾鏡來產生效果. 根據 WWDC 的 Core Image 影片, 效能會隨著影像
        解析度的比例而改變. 大約可降低 pixels 的數目 2.5 個係數.

沒有留言:

張貼留言

注意:只有此網誌的成員可以留言。