Декодер MJPEG

Брайен Пик (Brian Peek)

В прошлом году сотрудники Coding4Fun/Channel 9 попросили меня решить несколько задач для конференции MIX10. Одна из них — найти способ вывода видеопотока с веб-камеры в Windows Phone 7 для использования с проектом Клинта по созданию роботов, стреляющих футболками, о котором вы, возможно, читали. Самый простой из придуманных мной способов заключался в том, чтобы использовать сетевую (IP) камеру, способную передавать поток в формате Motion-JPEG. Этот поток можно легко декодировать и выводить на экран любыми средствами, позволяющими отображать JPEG-изображения. Вот так и родилась соответствующая библиотека.

Эта библиотека претерпела довольно много изменений, и я расширил ее так, чтобы можно было легко отображать MJPEG-потоки на самых разнообразных платформах. Разработчику достаточно сослаться на сборку, подходящую для нужной платформы, и добавить несколько строк кода — вот и все.

Применение

Для тех, кого интересует только использование библиотеки, все очень просто.

  1. Добавьте ссылку на одну из следующих сборок, подходящую для вашего проекта:
    • MjpegProcessorSL. dll – Silverlight (только вне браузера! );
    • MjpegProcessorWP7. dll – Windows Phone 7 (XNA или Silverlight, предельное быстродействие около 15 кадров/с при 320x240, поэтому настройте свою камеру соответствующим образом);
    • MjpegProcessorXna4.dll – XNA 4.0 (Windows);
    • MjpegProcessor.dll – Windows Forms и WPF.
  2. Создайте новый объект MjpegDecoder.
  3. Подключите событие FrameReady.
  4. В обработчике события принимайте Bitmap/BitmapImage и присваивайте его вашему элементу управления, который отвечает за вывод изображений.
  5. В случае XNA вызывайте метод GetMjpegFrame в методе Update, возвращающем Texture2D, который вы сможете использовать в своем методе Draw.
  6. Вызывайте метод ParseStream с передачей URI «конечной точки» MJPEG.

Все! И к исходному коду, и к двоичным файлам прилагаются проекты, демонстрирующие, как пользоваться этой библиотекой на каждой из платформ. Если вы добавили соответствующую ссылку, то можете просто скопировать код из примера и вставить в собственный проект (изменив URI, конечно).

 public partial class MainWindow : Window 

{ 

    MjpegDecoder _mjpeg; 

    public MainWindow() 

    { 

        InitializeComponent(); 

        _mjpeg = new MjpegDecoder(); 

        _mjpeg.FrameReady += mjpeg_FrameReady; 

    } 

    private void Start_Click(object sender, RoutedEventArgs e) 

    { 

        _mjpeg.ParseStream(new Uri("https://192.168.2.200/img/video.mjpeg")); 

    } 

    private void mjpeg_FrameReady(object sender, FrameReadyEventArgs e) 

{ 

        image.Source = e.BitmapImage; 

    } 

}

Если этот вариант вас не устраивает, вы можете обращаться к свойствам Bitmap/ BitmapImage прямо из объекта MjpegDecoder или из свойства CurrentFrame, которое будет содержать исходные JPEG-данные (до декодирования).

Пара слов о сетевых ( IP) камерах

Я протестировал библиотеку на нескольких камерах. У каждого устройства есть свои странности, но все они вроде бы работали с этой библиотекой за одним исключением: несколько камер иначе реагируют на ситуацию, когда вместе с HTTP-запросом посылается заголовок пользовательского агента Internet Explorer. Вместо отправки MJPEG-потока они в этом случае передают единственное JPEG-изображение, так как Internet Explorer толком не поддерживает MJPEG-потоки. Увы, это нарушает корректную работу исполняющей среды Silverlight, так как в упомянутом заголовке нельзя изменить значения, задаваемые Internet Explorer по умолчанию. Из-за этого и передается единственный кадр, а декодирование заканчивается неудачей. Мне удалось найти лишь один способ обойти эту проблему — взять другую камеру.

Что такое MJPEG?

Это просто-напросто формат видео, в котором каждый кадр посылается как отдельное, сжатое JPEG-изображение. Вы посылаете стандартный HTTP-запрос по конкретному URL и принимаете ответ, состоящий из множества порций. Разбивая этот «многопорционный» поток на отдельные изображения по мере их приема, вы получаете серию JPEG-изображений. Средство просмотра отображает эти JPEG-изображения сразу после приема каждого из них, и в итоге создается видео. Этот формат документирован не слишком хорошо и даже не полностью стандартизован, но работает. Более подробную информацию см. в статье о MJPEG в Википедии.

Как найти URL для MJPEG-потока от моей камеры?

Хороший вопрос. Чего не скажешь об ответе. Этот URL должен упоминаться в руководстве пользователя. Поможет и поиск в Интернете по номеру модели. Или попробуйте средство поиска, предлагаемое компанией SKJM.

Как все это работает?

Рад, что вы спросили. Если вы взглянете на проект, то заметите, что в нем не так много кода. Один-единственный файл используется с набором директив компилятора для компиляции определенных частей проекта в зависимости от выбранной сборки (определяющей целевую платформу). Вся реализация содержится в файле MjpegDecoder.cs/.vb.

Сначала в методе ParseStream выдается асинхронный запрос на переданный URL для MJPEG. В среде Silverlight свойство AllowReadStreamBuffering должно быть установлено в false, чтобы ответ не помещался в буфер, а возвращался немедленно. Кроме того, нужно зарегистрировать префикс https:// , чтобы использовать вместо HTTP-стека браузера HTTP-стек клиента. Наконец, выполняется запрос с применением метода BeginGetResponse; при этом в качестве обратного вызова указывается метод OnGetResponse. Он будет вызван, как только данные будут переданы камерой в ответ на наш запрос.

 public void ParseStream(Uri uri) 

{ 

#if SILVERLIGHT 

    HttpWebRequest.RegisterPrefix("https://", WebRequestCreator.ClientHttp); 

#endif 

    HttpWebRequest request = (HttpWebRequest)HttpWebRequest.Create(uri); 

#if SILVERLIGHT 

    // начать поток немедленно

    request.AllowReadStreamBuffering = false; 

#endif 

    // получить ответ асинхронно

request.BeginGetResponse(OnGetResponse, request); 

}

OnGetResponse получает заголовки ответа и использует заголовок Content-Type для определения разделителя, посылаемого между JPEG-кадрами.

 private void OnGetResponse(IAsyncResult asyncResult) 

{ 

    HttpWebResponse resp; 

    byte[] buff; 

    byte[] imageBuffer = new byte[1024 * 1024]; 

    Stream s; 

    // получить ответ

    HttpWebRequest req = (HttpWebRequest)asyncResult.AsyncState; 

    resp = (HttpWebResponse)req.EndGetResponse(asyncResult); 

// найти наше магическое граничное значение 

    string contentType = resp.Headers["Content-Type"]; 

    if(!string.IsNullOrEmpty(contentType) && !contentType.Contains("=")) 

        throw new Exception("Invalid content-type header.  The camera is likely not returning a proper MJPEG stream."); 

    string boundary = resp.Headers["Content-Type"].Split('=')[1]; 

    byte[] boundaryBytes = Encoding.UTF8.GetBytes(boundary.StartsWith("--") ? boundary : "--" + boundary); 

...

Затем код пересылает поток данных ответа, ищет маркер заголовка JPEG, читает, пока не натыкается на разделитель, копирует данные в буфер, декодирует их, передает всем желающим через событие, а потом все начинается сначала.

 ... 

    s = resp.GetResponseStream(); 

    BinaryReader br = new BinaryReader(s); 

    _streamActive = true; 

    buff = br.ReadBytes(ChunkSize); 

    while (_streamActive) 

    { 

        int size; 

        // найти заголовок JPEG 

        int imageStart = buff.Find(JpegHeader); 

        if(imageStart != -1) 

        { 

// скопировать начало JPEG-изображения в imageBuffer

            size = buff.Length - imageStart; 

            Array.Copy(buff, imageStart, imageBuffer, 0, size); 

            while(true) 

            { 

                buff = br.ReadBytes(ChunkSize); 

                // найти пограничный текст

                int imageEnd = buff.Find(boundaryBytes); 

                if(imageEnd != -1) 

                { 

                    // скопировать остаток JPEG в imageBuffer 

                    Array.Copy(buff, 0, imageBuffer, size, imageEnd); 

                    size += imageEnd; 

                    // создать один кадр JPEG 

                    CurrentFrame = new byte[size]; 

                    Array.Copy(imageBuffer, 0, CurrentFrame, 0, size); 

#if !XNA 

                    ProcessFrame(CurrentFrame); 

#endif 

                    // копировать оставшиеся данные в начало

                    Array.Copy(buff, imageEnd, buff, 0, buff.Length - imageEnd); 

                    // заполнить остаток буфера новыми данными и начать заново

                    byte[] temp = br.ReadBytes(imageEnd); 

                    Array.Copy(temp, 0, buff, buff.Length - imageEnd, temp.Length); 

                    break; 

                } 

                // скопировать все данные в imageBuffer 

                Array.Copy(buff, 0, imageBuffer, size, buff.Length); 

                size += buff.Length; 

            } 

        } 

    } 

    resp.Close(); 

}

Показанный выше метод ProcessFrame принимает буфер с исходными байтами, из которых состоит не декодированное JPEG-изображение, а затем декодирует его, учитывая целевую платформу. Однако в случае XNA он не вызывается, и вскоре мы увидим это:

 private void ProcessFrame(byte[] frameBuffer) 

{ 

#if SILVERLIGHT 

// требуется получить обратно в потоке пользовательского интерфейса 

    Deployment.Current.Dispatcher.BeginInvoke((Action)(() => 

    { 

        // обновить BitmapImage новым кадром

        BitmapImage.SetSource(new MemoryStream(frameBuffer, 0, frameBuffer.Length)); 

        // сообщить слушателям, что есть кадр для прорисовки

        if(FrameReady != null) 

            FrameReady(this, new FrameReadyEventArgs { FrameBuffer = CurrentFrame, BitmapImage = BitmapImage }); 

    })); 

#endif 

#if !SILVERLIGHT && !XNA 

// Предполагается, что если присутствует Application.Current, то мы находимся в WPF, а не в WinForms

    if(Application.Current != null) 

    { 

// получить его в потоке пользовательского интерфейса 

        Application.Current.Dispatcher.BeginInvoke((Action)(() => 

        { 

// создать новый BitmapImage из байтов JPEG

            BitmapImage = new BitmapImage(); 

            BitmapImage.BeginInit(); 

            BitmapImage.StreamSource = new MemoryStream(frameBuffer); 

            BitmapImage.EndInit(); 

// сообщить слушателям, что есть кадр для прорисовки 

            if(FrameReady != null) 

                FrameReady(this, new FrameReadyEventArgs { FrameBuffer = CurrentFrame, Bitmap = Bitmap, BitmapImage = BitmapImage }); 

        })); 

    } 

    else

    { 

// создать простой GDI+ Bitmap

        Bitmap = new Bitmap(new MemoryStream(frameBuffer)); 

// сообщить слушателям, что есть кадр для прорисовки 

        if(FrameReady != null) 

            FrameReady(this, new FrameReadyEventArgs { FrameBuffer = CurrentFrame, Bitmap = Bitmap, BitmapImage = BitmapImage }); 

} 

#endif 

}

В случае Silverlight у объекта BitmapImage имеется метод SetSource, который принимает поток, подлежащий декодированию и преобразованию в изображение. В WPF объект BitmapImage работает по-другому. Здесь вызывается BeginInit, далее в свойство StreamSource записывается поток байтов, потом вызывается EndInit. В Windows Forms библиотека вернет объект Bitmap, который можно инициализировать потоком прямо в конструкторе.

В приведенном выше коде я анализирую свойство Application. Current, чтобы определить, используется ли библиотека в WPF-проекте. Если это свойство не содержит null, предполагается, что библиотека вызывается из WPF-проекта.

При компиляции библиотеки под XNA нам не нужен ни BitmapImage, ни Bitmap, а требуется объект Texture2D. Для получения текущего кадра XNA-приложение вызывает из метода Update метод GetMjpegFrame, показанный ниже:

 public Texture2D GetMjpegFrame(GraphicsDevice graphicsDevice) 

{ 

// создать Texture2D из текущего буфера 

    if(CurrentFrame != null) 

        return Texture2D.FromStream(graphicsDevice, new MemoryStream(CurrentFrame, 0, CurrentFrame.Length)); 

return null; 

}

Заключение

Вот, собственно, и все, что я хотел рассказать об этой библиотеке. Опробуйте ее в действии и, пожалуйста, сообщите мне свое мнение или расскажите о любых проблемах, с которыми вы столкнулись при ее использовании. Наслаждайтесь!