Pull to refresh

Записываем видео из Google Street View

Reading time 8 min
Views 29K
Некоторое время назад стала популярной тема Hyperlapse/time-lapse видео. В первую очередь, благодаря небезызвестному ресурсу http://hyperlapse.tllabs.io/

Сама по-себе возможность, конечно же замечательная, но сайт не позволяет сохранять результаты экспериментов в виде роликов. Вот эту досадную неприятность решено было исправить, и не просто исправить, а реализовать в виде программки для iOS, помогая тем самым, превратить iPhone или iPad в устройство для создания, а не потребления, контента.
Как всё устроено
Итак, на сегодняшний день у нас есть несколько ресурсов, позволяющих снимать Стрит Видео. В первую очередь, это, hyperlapse.tllabs.io, который позволяет отметить 2 точки, проложить между ними маршрут и наслаждаться зацикленной анимацией.
image
Второй сайт, который позволяет смотреть стрит видео это http://track-kit.net
image
Этот сайт позволяет просматривать видео для созданных или импорированных треков. Несмотря на то, что Стрит Видео здесь не является основной функцией, можно сгенерировать прямую ссылку именно на видео для тека. Например, такую:
http://track-kit.net/maps_s3/index.php?track=8821.gpx&svv=134
Правда, на моём Маке более-менее работает только в Хроме.

Тем не менее, ни один из этих ресурсов не позволяет сохранять видео. Эту проблему мы сейчас и будем решать.
Для подготовки видео нам необходимо решить несколько задач.
  1. Проложить маршрут от точки А к точке Б. Желательно, отобразить доступность Гугл Стрит Вью.
  2. Загрузить кадры панорам
  3. Дать возможность пользователю отредактировать панорамы, например, направив камеру на какой-лиобо объект.
  4. Сгенерировать видео из набора кадров
  5. Решить ряд типичных для iOS проблемм.


Прокладываем маршрут

Для этого мы используем Google Maps SDK for iOS и Google direction API
С помощью Google direction API запрашиваем у Google набор точек между начальной и конечно точек пути в закодированном виде.
Google Maps SDK for iOS (класс GMSPath) понадобится чтобы перевести закодированый список точек который получили от Google в широту и долготу.
Для общения с Google используется AFNetworking.
static NSString *kLWSDirectionsURL = @"http://maps.googleapis.com/maps/api/directions/json?";
- (void)loadDirectionsForWaypoints:(NSArray *)waypoints{
        NSString *origin = [waypoints objectAtIndex:0];
        int waypointCount = [waypoints count];
        int destinationPos = waypointCount -1;
        NSString *destination = [waypoints objectAtIndex:destinationPos];
        NSString *sensor = @"false";
        NSMutableString *url = [NSMutableString stringWithFormat:@"%@&origin=%@&destination=%@&sensor=%@",
        kLWSDirectionsURL,origin,destination, sensor];
        if(waypointCount>2) {
            [url appendString:@"&waypoints=optimize:true"];
            int wpCount = waypointCount-2;
            for(int i=1;i<wpCount;i++){
                [url appendString: @"|"];
                [url appendString:[waypoints objectAtIndex:i]];
            }
        }
        url = [NSMutableString stringWithString:[url stringByAddingPercentEscapesUsingEncoding: NSASCIIStringEncoding]];
        _directionsURL = [NSURL URLWithString:url];
        [self startDownloadDataForURL:_directionsURL];
}

AFHTTPRequestOperation *requestOperation;
NSMutableArray* coordinatesArr;
-(void)startDownloadDataForURL:(NSURL*)url{
        [self stopLoadingForUserInfo:userInfo];
        requestOperation = [manager GET:[url absoluteString] parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
                NSString* status = [responseObject objectForKey:@"status"];
                NSArray* routesArr = [responseObject objectForKey:@"routes"];
                if ([status isEqualToString:@"OK"] && [routesArr count] > 0) {
                    NSDictionary *routes = [responseObject objectForKey:@"routes"][0];
                    NSDictionary *route = [routes objectForKey:@"overview_polyline"];
                    NSString *overview_route = [route objectForKey:@"points"];
                    GMSPath *path = [GMSPath pathFromEncodedPath:overview_route];
                    
                    coordinatesArr = [NSMutableArray array];
                    for (int i = 0; i < [path count]; ++i) {
                        CLLocationCoordinate2D coord = [path coordinateAtIndex:i];    
                        [coordinatesArr addObject:[NSValue valueWithMKCoordinate:coord]];
                    }
            }
        } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        }];
}

Если загрузка прошла успешно в списке coordinatesArr мы храним набор координат точек нашего пути.
P.S. у Google direction API есть 1 нюанс — если необходимо провести маршрут не через 2, а скажем, через 20 точек, то придется делать несколько запросов для интервалов точек пути так как если передать в запрос через «&waypoints» большое количество промежуточных точек, Google может вернуть ошибку.

Загружаем панорамы

Для загрузки панорамы можно использовать запрос вида cbk0.google.com/cbk?output=json&ll=latitude,longitude
Он нам вернет информацию о ближайшей к точке панораме с координатами latitude,longitude.
Самое важное что мы можем получить это «panoId» — id нужной нам панорамы (помимо panoID мы можем получить так же информацию об углах смещения панорамы, которые могут пригодиться если надо будет повернуть панораму в определенном направлении):
NSString* panoID;

-(void)loadMyWebViewForCoord:(CLLocationCoordinate2D)arg{
    @try {
        if (!manager) {
            manager = [AFHTTPRequestOperationManager manager];
        }
        
        NSString* urlStr = [NSString stringWithFormat:@"http://cbk0.google.com/cbk?output=json&ll=%f,%f",arg.latitude,arg.longitude];
        request = [manager GET:urlStr parameters:nil success:^(AFHTTPRequestOperation *operation, id responseObject) {
            id location = [responseObject objectForKey:@"Location"];
            id projection = [responseObject objectForKey:@"Projection"];
            if (location && projection) {
                panoID = [location objectForKey:@«panoId»];
}
        } failure:^(AFHTTPRequestOperation *operation, NSError *error) {
        }];
}

Далее с помощью полученного ID панорамы мы можем через запрос:
cbk0.google.com/cbk?output=tile&panoid=panoid&zoom=zoom&x=x&y=y
получить уже необходимые нам тайлы панорамы, где panoId — это полученный ранее идентификатор панорамы, zoom — это масштаб панорамы (ее размер), x и y — это номера тайла панорамы по вертикали и горизонтали, при этом количество тайлов панорамы зависит от введенного нами зума. Например, если мы выберем zoom = 3, то панорама будет состоять из 7 тайлов в ширину и 3 в высоту.
То есть чтобы получить целую панораму нам надо загрузить все тайлы:
-(void)loadImagesForPanoPoint:(PanoPoint*)currentPanoPointArg {
    @try {
        int zoom;
        int maxX;
        int maxY;
        if ([StreetViewSettings instance].hiQualityPano) {
            zoom = 3;
            maxX = 7;
            maxY = 3;
        }
        else {
            zoom = 2;
            maxX = 4;
            maxY = 2;
        }
        __block int allImages = maxX;
        for (int x = 0; x < maxX; ++x) {
            dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
                NSMutableArray* imForCurrentCoodY = [NSMutableArray array];
                for (int y = 0; y < maxY; ++y) {
                    @autoreleasepool {
                        NSString* pathStr = [NSString stringWithFormat:@"http://cbk0.google.com/cbk?output=tile&panoid=%@&zoom=%d&x=%d&y=%d",currentPanoPointArg.panoID,zoom,x,y];
                        NSString* tempDirectory = NSTemporaryDirectory();
                        NSString* imPath = [NSString stringWithFormat:@"%@/panoLoadCash/%@zoom=%dx=%dy=%d_%d",tempDirectory,currentPanoPointArg.panoID,zoom,x,y,currentCoordArrIndex];
                        NSData* im = nil;
                        NSFileManager* fM = [NSFileManager defaultManager];
                        BOOL isD;
                        if (![fM fileExistsAtPath:imPath isDirectory:&isD]) {
                            im = [self imgByPath:pathStr];
                        }
                        else {
                            [imForCurrentCoodY addObject:imPath];
                        }
                        if (im) {
                            pakSize += im.length;
                            if (![fM fileExistsAtPath:[NSTemporaryDirectory() stringByAppendingString:@"panoLoadCash"] isDirectory:&isD]) {
                                NSError* err;
                                [fM createDirectoryAtPath:[NSTemporaryDirectory() stringByAppendingString:@"panoLoadCash"] withIntermediateDirectories:YES attributes:[NSDictionary dictionary] error:&err];
                            }
                            [im writeToFile:imPath atomically:YES];
                            
                            [imForCurrentCoodY addObject:imPath];
                        }
                    }
                }
                [imForCurrentCoordV addObject:@(x)];
                [imForCurrentCoordTemp addObject:imForCurrentCoodY];
                --allImages;
            });
        }
}

Таким образом, пройдясь по всем точкам полученного от Google маршрута, мы можем загрузить для них панорамы и далее отобразить их пользователю в виде видео.

Генерируем видео

Для этого нам понадобится библиотека AVFoundation:
#import <AVFoundation/AVFoundation.h>

От туда берем всего 3 класса:
AVAssetWriter — запись медиа данных в файл
AVAssetWriterInput — Добавляет пакет медиаданных в AVAssetWriter для записи в файл
AVAssetWriterInputPixelBufferAdaptor — предоставляет пакет видеоданных (CVPixelBuffer) для AVAssetWriterInput
Соответственно нам надо их где-то определить:
    AVAssetWriter* videoWriter;
    AVAssetWriterInput* writerInput;
    AVAssetWriterInputPixelBufferAdaptor* adaptor;

Далее инициализация:
NSError *error = nil;
    videoWriter = [[AVAssetWriter alloc] initWithURL:[NSURL fileURLWithPath:videoPath]
                                            fileType:AVFileTypeQuickTimeMovie
                                               error:&error];
    
    NSDictionary *videoSettings = [[NSDictionary alloc] initWithObjectsAndKeys:
                                   AVVideoCodecH264, AVVideoCodecKey,
                                   [NSNumber numberWithInt:videoSize.width], AVVideoWidthKey,
                                   [NSNumber numberWithInt:videoSize.height], AVVideoHeightKey,
                                   nil];
    
    writerInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
    
    adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:writerInput sourcePixelBufferAttributes:nil];
    
    [videoWriter addInput:writerInput];
    [videoWriter startWriting];
    [videoWriter startSessionAtSourceTime:kCMTimeZero]

После этого все готово к записи видео.
В AVAssetWriterInput имеется функция:
(void)requestMediaDataWhenReadyOnQueue:(dispatch_queue_t)queue usingBlock:(void (^)(void))block
Которая, вызывает Block каждый раз когда нужна новая порция данных.
[writerInput requestMediaDataWhenReadyOnQueue:assetWriterQueue usingBlock:^
            if (buffer == NULL)
            {
                CVPixelBufferPoolCreatePixelBuffer (NULL, adaptor.pixelBufferPool, &buffer);
            }
            UIImage *image = [self imageForIndex:currentIndexForBuff];

            if (image) {
                buffer = [self pixelBufferFromCGImage:image.CGImage];

                CMTime presentationTime= CMTimeMakeWithSeconds(speed*currentIndexForBuff, 33);
                if (![adaptor appendPixelBuffer:buffer withPresentationTime:presentationTime]) {
                    [self finishVideo];
                    return;
                }
                CVPixelBufferRelease(buffer);
                if (currentIndexForBuff < imagesPathsForVideo.count) {
                }
                else {
                    [self finishVideo];
                }
            }
            else {
                if (currentIndexForBuff < imagesPathsForVideo.count) {
                }
                else {
                    [self finishVideo];
                }
                return;
            }
            ++currentIndexForBuff;
}];

Скорость проигрывания видео контролируется с помощью переменной presentationTime, которая указывает время кадра в выходном файле
UIImage *image — это текущий кадр
Когда все кадры записаны в видео, мы сообщаем videoWriter и writerInput о том что необходимо остановить запись видео:
-(void)finishVideo {
    [writerInput markAsFinished];
    [videoWriter finishWritingWithCompletionHandler:^(){}];
}

Функция получения CVPixelBufferRef с изображения:
- (CVPixelBufferRef) pixelBufferFromCGImage: (CGImageRef) image
{
    if (image) {
        NSDictionary *options = [[NSDictionary alloc] initWithObjectsAndKeys:
                             [NSNumber numberWithBool:YES], kCVPixelBufferCGImageCompatibilityKey,
                             [NSNumber numberWithBool:YES], kCVPixelBufferCGBitmapContextCompatibilityKey,
                                 nil];
        
        CVPixelBufferRef pxbuffer = NULL;
        CVPixelBufferCreate(kCFAllocatorDefault, CGImageGetWidth(image),
                            CGImageGetHeight(image), kCVPixelFormatType_32ARGB, (__bridge CFDictionaryRef) options,
                            &pxbuffer);
        
        CVPixelBufferLockBaseAddress(pxbuffer, 0);
        void *pxdata = CVPixelBufferGetBaseAddress(pxbuffer);
        
        CGColorSpaceRef rgbColorSpace = CGColorSpaceCreateDeviceRGB();
        CGContextRef context = CGBitmapContextCreate(pxdata, CGImageGetWidth(image), CGImageGetHeight(image), 8, 4*CGImageGetWidth(image), rgbColorSpace, kCGImageAlphaNoneSkipFirst);
        
        CGContextDrawImage(context, CGRectMake(0, 0, CGImageGetWidth(image), CGImageGetHeight(image)), image);
        CGColorSpaceRelease(rgbColorSpace);
        CGContextRelease(context);
        
        CVPixelBufferUnlockBaseAddress(pxbuffer, 0);
        
        return pxbuffer;
    }
    else {
        return nil;
    }
}


Работа в фоновом режиме

Чтобы видео продолжало генерироваться когда наше приложение находится в фоновом режиме, можно использовать long-running background task для этого советую использовать неплохой класс
https://github.com/vaskravchuk/VideoMaker/

Добавляя немножко опций, получаем такой вот программный продукт.
itunes.apple.com/us/app/street-video-maker-free-create/id788610126?mt=8
image
Вот пример видео, созданного при помощи такой программы:

Одним из интересных применений стрит видео был ныне покойный сайт 360° Langstrasse. От которого осталость только видео:



При помощи этой технологии можно создавать интересные проекты дополненной реальности, провдить географические изыскания и, конечно же. Развлекаться. На последок, немножко профессионального Time-lapse от Gunther Wegner
Tags:
Hubs:
+44
Comments 13
Comments Comments 13

Articles