Декодер MJPEG
В прошлом году сотрудники Coding4Fun/Channel 9 попросили меня решить несколько задач для конференции MIX10. Одна из них — найти способ вывода видеопотока с веб-камеры в Windows Phone 7 для использования с проектом Клинта по созданию роботов, стреляющих футболками, о котором вы, возможно, читали. Самый простой из придуманных мной способов заключался в том, чтобы использовать сетевую (IP) камеру, способную передавать поток в формате Motion-JPEG. Этот поток можно легко декодировать и выводить на экран любыми средствами, позволяющими отображать JPEG-изображения. Вот так и родилась соответствующая библиотека.
Эта библиотека претерпела довольно много изменений, и я расширил ее так, чтобы можно было легко отображать MJPEG-потоки на самых разнообразных платформах. Разработчику достаточно сослаться на сборку, подходящую для нужной платформы, и добавить несколько строк кода — вот и все.
Применение
Для тех, кого интересует только использование библиотеки, все очень просто.
- Добавьте ссылку на одну из следующих сборок, подходящую для вашего проекта:
- MjpegProcessorSL. dll – Silverlight (только вне браузера! );
- MjpegProcessorWP7. dll – Windows Phone 7 (XNA или Silverlight, предельное быстродействие около 15 кадров/с при 320x240, поэтому настройте свою камеру соответствующим образом);
- MjpegProcessorXna4.dll – XNA 4.0 (Windows);
- MjpegProcessor.dll – Windows Forms и WPF.
- Создайте новый объект MjpegDecoder.
- Подключите событие FrameReady.
- В обработчике события принимайте Bitmap/BitmapImage и присваивайте его вашему элементу управления, который отвечает за вывод изображений.
- В случае XNA вызывайте метод GetMjpegFrame в методе Update, возвращающем Texture2D, который вы сможете использовать в своем методе Draw.
- Вызывайте метод 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;
}
Заключение
Вот, собственно, и все, что я хотел рассказать об этой библиотеке. Опробуйте ее в действии и, пожалуйста, сообщите мне свое мнение или расскажите о любых проблемах, с которыми вы столкнулись при ее использовании. Наслаждайтесь!