Pull to refresh

Захват видео с сетевых камер, часть 2

Reading time 19 min
Views 251K

В первой своей статье «измерение расстояния до объекта и его скорости» я рассмотрел захват изображений с веб-камер через Video4Linux2 и через DirectX. В следующей статье «захват видео с сетевых камер, часть 1» я рассмотрел как работать с сетевыми Motion-JPEG камерами. Сейчас я поведаю Вам о захвате изображений с сетевых RTSP камер, в частности поток Motion-JPEG по RTSP.

Задача эта более сложная нежели Motion-JPEG по HTTP, так как необходимо больше действий, больше подключений, но взамен мы получаем большую гибкость, скорость, функциональность и даже некую универсальность. Честно говоря, RTSP для простых задач избыточен, но я не сомневаюсь, что найдутся ситуации, где он будет необходим.

Что такое RTSP


RTSP расшифровывается как Real Time Streaming Protocol — потоковый протокол реального времени — по сути это протокол управления вещанием, он позволяет выполнять несколько команд, такие как «старт», «стоп», «переход на определённое время». Протокол этот подобен HTTP в реализации, тоже есть заголовки, тоже всё передаётся в текстовом виде. Вот основные его команды из спецификации:
  • OPTIONS — возвращает список поддерживаемых методов (OPTIONS, DESCRIBE и т.д.);
  • DESCRIBE — запрос описания контента, описывает каждый трек в формате SDP;
  • SETUP — запрос установки соединений и транспорта для потоков;
  • PLAY — старт вещания;
  • TEARDOWN — остановка вещания.
И особенность RTSP в том, что он сам по себе не передаёт нужные нам видео данные! Целый протокол только для установления связи. Здесь просматривается аналогия с MVC, идёт разделение между данными и их описанием.

Рабочей лошадкой является другой протокол: RTP — Real-time Transport Protocol — транспортный протокол реального времени. С его помощью и передаются нужные нам данные. Стоит отметить, что с этим протоколом очень даже приятно работать, дело в том, что он облегчает клиентскому ПО восстановление данных после их фрагментации на канальном уровне. А также несёт в себе ещё несколько полезных полей: формат передаваемых данных, временную метку и поле синхронизации (если передаётся, например, одновременно аудио и видео). Хотя этот протокол может работать по TCP, его обычно используют с UDP из-за его ориентированности на скорость. То есть RTP данные это UDP датаграмма с заголовком и полезными данными медиа-контента (payload).

Казалось бы нам больше ничего и не нужно. Подключаемся по RTSP, забираем по RTP. Но не тут-то было, умные дяди придумали третий протокол: RTCP — Real-time Transport Control Protocol — протокол контроля за транспортом в реальном времени. Этот протокол служит для определения качества сервиса, с его помощью клиент и сервер знают как хорошо или плохо идёт передача контента. В соответствии с этими данными сервер, например, может понизить битрейт или вообще перейти на другой кодек.

Принято, что RTP использует чётный номер порта, а RTCP следующий нечётный.

Пример общения по RTSP

У меня только один источник RTSP потока — камера eVidence APIX Box M1, поэтому все примеры относятся к ней.

Ниже лог общения между плеером VLC (он правда мне очень помогает в моих исследованиях) и этой камерой. Первый запрос от VLC на порт 554 камеры. Ответ через пустую строку и начинается с «RTSP/1.0».

01: OPTIONS rtsp://192.168.0.254/jpeg RTSP/1.0
02: CSeq: 1
03: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
04: 
05: RTSP/1.0 200 OK
06: CSeq: 1
07: Date: Fri, Apr 23 2010 19:54:20 GMT
08: Public: OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY, PAUSE
09: 
10: DESCRIBE rtsp://192.168.0.254/jpeg RTSP/1.0
11: CSeq: 2
12: Accept: application/sdp
13: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
14: 
15: RTSP/1.0 200 OK
16: CSeq: 2
17: Date: Fri, Apr 23 2010 19:54:20 GMT
18: Content-Base: rtsp://192.168.0.254/jpeg/
19: Content-Type: application/sdp
20: Content-Length: 442
21: x-Accept-Dynamic-Rate: 1
22: 
23: v=0
24: o=- 1272052389382023 1 IN IP4 0.0.0.0
25: s=Session streamed by "nessyMediaServer"
26: i=jpeg
27: t=0 0
28: a=tool:LIVE555 Streaming Media v2008.04.09
29: a=type:broadcast
30: a=control:*
31: a=range:npt=0-
32: a=x-qt-text-nam:Session streamed by "nessyMediaServer"
33: a=x-qt-text-inf:jpeg
34: m=video 0 RTP/AVP 26
35: c=IN IP4 0.0.0.0
36: a=control:track1
37: a=cliprect:0,0,720,1280
38: a=framerate:25.000000
39: m=audio 7878 RTP/AVP 0
40: a=rtpmap:0 PCMU/8000/1
41: a=control:track2
42: 
43: 
44: SETUP rtsp://192.168.0.254/jpeg/track1 RTSP/1.0
45: CSeq: 3
46: Transport: RTP/AVP;unicast;client_port=41760-41761
47: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
48: 
49: RTSP/1.0 200 OK
50: CSeq: 3
51: Cache-Control: must-revalidate
52: Date: Fri, Apr 23 2010 19:54:20 GMT
53: Transport: RTP/AVP;unicast;destination=192.168.0.4;source=192.168.0.254;client_port=41760-41761;
            server_port=6970-6971
54: Session: 1
55: x-Transport-Options: late-tolerance=1.400000
56: x-Dynamic-Rate: 1
57: 
58: SETUP rtsp://192.168.0.254/jpeg/track2 RTSP/1.0
59: CSeq: 4
60: Transport: RTP/AVP;unicast;client_port=7878-7879
61: Session: 1
62: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
63: 
64: RTSP/1.0 200 OK
65: CSeq: 4
66: Cache-Control: must-revalidate
67: Date: Fri, Apr 23 2010 19:54:20 GMT
68: Transport: RTP/AVP;unicast;destination=192.168.0.4;source=192.168.0.254;client_port=7878-7879;
            server_port=6972-6973
69: Session: 1
70: x-Transport-Options: late-tolerance=1.400000
71: x-Dynamic-Rate: 1
72: 
73: PLAY rtsp://192.168.0.254/jpeg/ RTSP/1.0
74: CSeq: 5
75: Session: 1
76: Range: npt=0.000-
77: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
78: 
79: RTSP/1.0 200 OK
80: CSeq: 5
81: Date: Fri, Apr 23 2010 19:54:20 GMT
82: Range: npt=0.000-
83: Session: 1
84: RTP-Info: url=rtsp://192.168.0.254/jpeg/track1;seq=20730;
            rtptime=3869319494,url=rtsp://192.168.0.254/jpeg/track2;seq=33509;rtptime=3066362516
85: 
86: # В этот момент начинается передача контента и следующая команда вызывается для остановки вещания
87: 
88: TEARDOWN rtsp://192.168.0.254/jpeg/ RTSP/1.0
89: CSeq: 6
90: Session: 1
91: User-Agent: VLC media player (LIVE555 Streaming Media v2008.07.24)
92: 
93: RTSP/1.0 200 OK
94: CSeq: 6
95: Date: Fri, Apr 23 2010 19:54:25 GMT

Первым делом VLC спрашивает камеру:
— А что я вообще могу с тобой делать? (OPTIONS)
— И тебе привет. А можешь ты меня просить сделать любое из OPTIONS, DESCRIBE, SETUP, TEARDOWN, PLAY и PAUSE.
— Ладно, тогда скажи мне что у тебя есть по запросу "/jpeg"? (DESCRIBE)
— Тут у меня видео первой дорожкой идёт, M-JPEG, а второй дорожкой идёт аудио простое.
— Интересно глянуть на видео, первую дорожку, отсыпь мне его, пожалуйста в карман номер 41760, а шелуху всякую можешь в карман номер 41761 скидывать. (SETUP track1)
— ОК, по твоей команде…
— И звук тоже хочу послушать, сыпь в 7878, 7879 карманы. (SETUP track2)
— Да без проблем.
— Ну, посыпали. (PLAY)
Через некоторое время:
— Ладно, хватит, насмотрелся. (TEARDOWN)
— Как скажешь.

На этом небольшое лирическое отступление заканчивается. В первом запросе "OPTIONS rtsp://192.168.0.254/jpeg RTSP/1.0" напоминает "GET /jpeg HTTP/1.1" в том смысле, что с этого начинается разговор, а так у протокола HTTP тоже есть метод OPTIONS. Здесь 192.168.0.254 — это IP адрес моей камеры. CSeq отражает порядковый номер запроса, ответ от сервера должен содержать тот же самый CSeq.

А ответ от сервера начинается с "RTSP/1.0 200 OK", это прямо как "HTTP/1.1 200 OK" — знак, что всё хорошо: запрос принят, запрос понятен и не было никаких проблем в его реализации. И прямым текстом следует перечисление всех доступных методов.

Далее мы собираем информацию о том, что нас ждёт по запросу /jpeg, ведь мы именно за ним и пришли по ссылке "rtsp://192.168.0.254/jpeg". Также указываем, что хотим получить ответ в виде SDP (строка 12).

В ответ нам приходит RTSP заголовок с указанием Content-Type и Content-Length, а после заголовка через пустую строку непосредственно сам контент в формате SDP:

v=0
o=- 1272052389382023 1 IN IP4 0.0.0.0
s=Session streamed by "nessyMediaServer"
i=jpeg
t=0 0
a=tool:LIVE555 Streaming Media v2008.04.09
a=type:broadcast
a=control:*
a=range:npt=0-
a=x-qt-text-nam:Session streamed by "nessyMediaServer"
a=x-qt-text-inf:jpeg
m=video 0 RTP/AVP 26
c=IN IP4 0.0.0.0
a=control:track1
a=cliprect:0,0,720,1280
a=framerate:25.000000
m=audio 7878 RTP/AVP 0
a=rtpmap:0 PCMU/8000/1
a=control:track2

Здесь всё достаточно очевидно. Нужны нам следующие строки:

# Для видео
m=video 0 RTP/AVP 26 # Транспорт потока RTP/AVP, порт любой, видео формат 26, что соответствует Motion-JPEG
a=control:track1 # Название трека
a=cliprect:0,0,720,1280 # Отсюда вытаскиваем разрешение
a=framerate:25.000000 # И частота кадров если нам понадобится

# Для аудио
m=audio 7878 RTP/AVP 0 # Порт 7878, транспорт и формат аудио, 0 - PCM
a=control:track2 # Название трека

Если мы хотим получать только видео, то из аудио данных мы игнорируем всё, кроме названия трека. Он нам нужен, чтобы настроить поток, но нас никто не заставляет этот поток принимать, однако камера отказывается работать, если игнорировать аудио полностью (если делать SETUP только для видео трека).

Честно говоря, я не знаю как будут реагировать разные камеры, если пренебрегать номером порта для аудио потока (7878), ведь мы его указываем с командой SETUP.

Далее идут два запроса SETUP, c указанием портов, на которые мы бы хотели принимать видео и аудио потоки. Первое число — порт для RTP, второе — для RTCP. В ответе камеры содержится информация о портах, с ними можно сверяться, чтобы удостовериться, что всё настроено правильно. Ещё нам необходимо запомнить идентификатор Session. Мы должны будем указывать его во всех последующих вызовах.

После команды PLAY начнётся передача видео на порт 41760 и аудио на порт 7878. И по команде TEARDOWN вещание прекращается, соединение разрывается.

MJPEG over RTP

К нам приходят RTP пакеты, нам их нужно расшифровать. Для этого я приведу здесь таблицу такого пакета с описанием всех полей.
+ Bit offset 0-1 2 3 4-7 8 9-15 16-31
0 V P X CC M PT Sequence Number
32 Timestamp
64 SSRC Identifier
96 … CSRC Identifiers …
96+(CC×32) Extension Header ID Extension Header Length (EHL)
96+(CC×32)+(X×32) … Extension Header …
96+(CC×32)+(X×32)+(X×EHL) Payload

  1. V (Version): (2) версия протокола. Сейчас номер версии 2.
  2. P (Padding, Дополнение): (1) используется в случаях, когда RTP-пакет дополняется пустыми байтами в конце, например для алгоритмов шифрования.
  3. X (Extension, Расширение): (1) указывает на наличие расширенного заголовка, определяется приложением. В нашем случае это не используется.
  4. CC (CSRC Count): (4) содержит количество CSRC-идентификаторов. Нами тоже не используется.
  5. M (Marker): (1) используется на уровне приложения, в нашем случае этот бит выставляется в единицу, если RTP пакет содержит окончание JPEG кадра.
  6. PT (Payload Type): (7) указывает формат полезной нагрузки — передаваемых данных. Для MJPEG это 26.
  7. Sequence Number: (16) номер RTP пакета, используется для обнаружения потерянных пакетов.
  8. Timestamp (32): временная метка, в нашем случае 90000 герцовая (90000 = 1 секунда).
  9. SSRC (Synchronization Source): (32) идентификатор синхронизатора, как смешно бы это не звучало. Определяет источник потока.
  10. CSRC (Contributing Source): (32) идентификаторы дополнительных источников, используется когда у нас поток идёт с нескольких мест.
  11. Extension Header ID: (16) идентификатор расширения, если оно у нас есть надо знать что оно из себя представляет. В нашем случае не используется.
  12. Extension Header Length: (16) длинна этого заголовка в байтах.
  13. Extension Header (Заголовок Расширения): сам заголовок. Содержимое может быть самым разным, зависит от контекста.
  14. Payload (Нагрузка): полезные данные — те самые наши JPEG кадры. Фрагментированные, конечно.
Поля начиная с CSRC необязательные. Для передачи MJPEG с камер они не используются, на сколько я знаю.

Переносимся на один уровень инкапсуляции выше. Теперь стоит задача преобразовать получаемые видео данные в полноценное JPEG изображение. В случае MJPEG по HTTP всё просто — вырезаем кусок потока и работаем с ним сразу как с JPEG изображением. В случае же RTP изображение передаётся не полностью, JPEG заголовок опускается для экономии трафика. Его необходимо восстановить самостоятельно из прилагаемых данных.

Спецификация RTP Payload for MJPEG описана в RFC2435. Я также приведу Вам таблицу с описанием всех полей формата:
+ Bit offset 0-7 8-15 16-23 24-31
0 Type-specific Fragment Offset
32 Type Q Width Height
if Type in 64..127 Restart Marker header
if Q in 128..255 MBZ Precision Length
Quantization Table Data

  1. Type-specific (Зависит от типа): (8) смысл поля зависит от реализации, в нашем случае не применяется.
  2. Fragment Offset (Смещение фрагмента): (24) указывает на положение текущего фрагмента кадра во всём кадре.
  3. Type (Тип): (8) от типа зависит как восстанавливается изображение.
  4. Q (Quality): (8) качество изображения.
  5. Width: (8) ширина кадра.
  6. Height: (8) и высота.
  7. Restart Marker header (Заголовок маркеров RST): (32) используется при декодировании JPEG, если применяются RST маркеры. Не знаю используют их камеры или нет, но я этот заголовок игнорирую. Это поле появляется только при Type от 64 до 127.
  8. Quantization Table Data (Таблицы квантинизации): если они присутствуют, то не нужно их отдельно вычислять. А нужны они для правильного воссоздания картинки из JPEG данных. Если эти таблицы не правильные, то изображение будет с неправильными цветами и контрастами. Таблиц должно быть две: Luma и Chroma для яркости и цветности соответственно.
  9. MBZ, Precision, Length: (32) параметры таблиц квантинизации, я их игнорирую, Length задаю равным 128 — две таблицы по 64 байт. В ином случае я не знаю как с ними работать.
Заголовок маркеров RST и таблицы квантинизации могут и не присутствовать. Если нет первого, то очень хорошо, так как на другое я и не рассчитываю. Если нет второго — нужные таблицы вычисляются исходя из параметра Q.

RTCP пакет содержит в себе некоторое подмножество, он бывает четырёх типов: 201 — отчёт источника, 202 — отчёт получателя, 203 — описание источников и 204 — назначение определяется приложением. Мы должны принимать в первую очередь 201 тип, затем отправлять 202 тип. 203 и 204 необязательны, но я их тоже учитываю. В одном UDP пакете может быть несколько RTCP пакетов.

Все типы имеют похожую структуру. Начинается любой RTCP пакет со следующих данных:
+ Bit offset 0-1 2 3-7 8-15 16-31
0 Version Padding SC or RC or Subtype Packet Type Length

  1. Version: (2) версия RTP.
  2. Padding: (1) то же самое, что и для RTP.
  3. SC or RC or Subtype: (5) в зависимости от типа может быть количеством источников (Sources Count) или количеством получателей (Receivers Count) включенных в отчёт получателя и источника соответсвенно. Если это APP пакет, то это поле определяет подтип такого пакета.
  4. Packet Type: (8) тип пакета, 201 — отчёт источника (Sender's Report SS), 202 — отчёт получателя (Receiver's Report RR), 203 — описание источников (Source Description SDES) и 204 — назначение определяется приложением (APP).
  5. Length: (16) размер следующих за заголовком данных, измеряется в 32 битных единицах.
Далее я не буду приводить поля для каждого подтипа, их можно посмотреть в RFC3550. Скажу лишь что SS и RR типы несут в себе информацию об отправленных/полученных пакетах и о временных задержках. SDES в себе несёт разные текстовые поля, определяющие источник, такие как его имя, email, телефон, местонахождение и т.п.

На этом введение заканчивается.

Python MJPEG over RTSP client


Вот мы и добрались до питона. Клиент состоит из нескольких файлов, main.py содержит в себе callback функцию, которая обрабатывает получаемые изображения, также он запускает механизмы сетевого фреймворка Twisted и хранит в себе параметры подключения к камере. Все листинги я привожу укороченными, полную версию можно скачать по ссылке в конце статьи.
main.py
20:	def processImage(img):
21:	    'This function is invoked by the MJPEG Client protocol'
22:	    # Process image
23:	    # Just save it as a file in this example
24:	    f = open('frame.jpg', 'wb')
25:	    f.write(img)
26:	    f.close()
27:	
28:	def main():
29:	    print 'Python M-JPEG Over RSTP Client 0.1'
30:	    config = {'request': '/jpeg',
31:	          'login': '',
32:	          'password': 'admin',
33:	          'ip': '192.168.0.252',
34:	          'port': 554,
35:	          'udp_port': 41760,
36:	          'callback': processImage}
37:	    # Prepare RTP MJPEG client (technically it's a server)
38:	    reactor.listenUDP(config['udp_port'], rtp_mjpeg_client.RTP_MJPEG_Client(config))
39:	    reactor.listenUDP(config['udp_port'] + 1, rtcp_client.RTCP_Client()) # RTCP
40:	    # And RSTP client
41:	    reactor.connectTCP(config['ip'], config['port'], rtsp_client.RTSPFactory(config))
42:	    # Run both of them
43:	    reactor.run()
44:	    # On exit:
45:	    print 'Python M-JPEG Client stopped.'

В принципе можно работать и без реализации RTCP протокола и приёма аудио данных. В этом случае камера разрывает соединение через примерно минуту. Приходится всё время переподключаться, это делается автоматически, поэтому проблем не доставляет. Однако для статьи я дописал RTCP часть и сделал заготовку для приёма аудио данных.

Следующим важным файлом является rtsp_client.py. Он самый запутанный, но его цель очевидна — правильно установить соединение, описанное выше.
rtsp_client.py
012:	class RTSPClient(Protocol):
013:	    def __init__(self):
014:	        self.config = {}
015:	        self.wait_description = False
016:	
017:	    def connectionMade(self):
018:	        self.session = 1
019:	        # Authorization part
020:	        if self.config['login']:
021:	            authstring = 'Authorization: Basic ' + b64encode(self.config['login']+':'+self.config['password']) + '\r\n'
022:	        else:
023:	            authstring = ''
024:	        # send OPTIONS request
025:	        to_send = """\
026:	OPTIONS rtsp://""" + self.config['ip'] + self.config['request'] + """ RTSP/1.0\r
027:	""" + authstring + """CSeq: 1\r
028:	User-Agent: Python MJPEG Client\r
029:	\r
030:	"""
031:	        self.transport.write(to_send)
032:	        if debug:
033:	            print 'We say:\n', to_send
034:	    
035:	    def dataReceived(self, data):
036:	        if debug:
037:	            print 'Server said:\n', data
038:	        # Unify input data
039:	        data_ln = data.lower().strip().split('\r\n', 5)
040:	        # Next behaviour is relevant to CSeq
041:	        # which defines current conversation state
042:	        if data_ln[0] == 'rtsp/1.0 200 ok' or self.wait_description:
043:	            # There might be an audio stream
044:	            if 'audio_track' in self.config:
045:	                cseq_audio = 1
046:	            else:
047:	                cseq_audio = 0
048:	            to_send = ''
049:	            if 'cseq: 1' in data_ln:
050:	                # CSeq 1 -> DESCRIBE
051:	                to_send = """\
052:	DESCRIBE rtsp://""" + self.config['ip'] + self.config['request'] + """ RTSP/1.0\r
053:	CSeq: 2\r
054:	Accept: application/sdp\r
055:	User-Agent: Python MJPEG Client\r
056:	\r
057:	"""
058:	            elif 'cseq: 2' in data_ln or self.wait_description:
059:	                # CSeq 2 -> Parse SDP and then SETUP
060:	                data_sp = data.lower().strip().split('\r\n\r\n', 1)
061:	                # wait_description is used when SDP is sent in another UDP
062:	                # packet
063:	                if len(data_sp) == 2 or self.wait_description:
064:	                    # SDP parsing
065:	                    video = audio = False
066:	                    is_MJPEG = False
067:	                    video_track = ''
068:	                    audio_track = ''
069:	                    if len(data_sp) == 2:
070:	                        s = data_sp[1].lower()
071:	                    elif self.wait_description:
072:	                        s = data.lower()
073:	                    for line in s.strip().split('\r\n'):
074:	                        if line.startswith('m=video'):
075:	                            video = True
076:	                            audio = False
077:	                            if line.endswith('26'):
078:	                                is_MJPEG = True
079:	                        if line.startswith('m=audio'):
080:	                            video = False
081:	                            audio = True
082:	                            self.config['udp_port_audio'] = int(line.split(' ')[1])
083:	                        if video:
084:	                            params = line.split(':', 1)
085:	                            if params[0] == 'a=control':
086:	                                video_track = params[1]
087:	                        if audio:
088:	                            params = line.split(':', 1)
089:	                            if params[0] == 'a=control':
090:	                                audio_track = params[1]
091:	                    if not is_MJPEG:
092:	                        print "Stream", self.config['ip'] + self.config['request'], 'is not an MJPEG stream!'
093:	                    if video_track: self.config['video_track'] = 'rtsp://' + self.config['ip'] + self.config['request'] + '/' + basename(video_track)
094:	                    if audio_track: self.config['audio_track'] = 'rtsp://' + self.config['ip'] + self.config['request'] + '/' + basename(audio_track)
095:	                    to_send = """\
096:	SETUP """ + self.config['video_track'] + """ RTSP/1.0\r
097:	CSeq: 3\r
098:	Transport: RTP/AVP;unicast;client_port=""" + str(self.config['udp_port']) + """-"""+ str(self.config['udp_port'] + 1) + """\r
099:	User-Agent: Python MJPEG Client\r
100:	\r
101:	"""
102:	                    self.wait_description = False
103:	                else:
104:	                    # Do not have SDP in the first UDP packet, wait for it
105:	                    self.wait_description = True
106:	            elif "cseq: 3" in data_ln and 'audio_track' in self.config:
107:	                # CSeq 3 -> SETUP audio if present
108:	                self.session = data_ln[5].strip().split(' ')[1]
109:	                to_send = """\
110:	SETUP """ + self.config['audio_track'] + """ RTSP/1.0\r
111:	CSeq: 4\r
112:	Transport: RTP/AVP;unicast;client_port=""" + str(self.config['udp_port_audio']) + """-"""+ str(self.config['udp_port_audio'] + 1) + """\r
113:	Session: """ + self.session + """\r
114:	User-Agent: Python MJPEG Client\r
115:	\r
116:	"""
117:	                reactor.listenUDP(self.config['udp_port_audio'], rtp_audio_client.RTP_AUDIO_Client(self.config))
118:	                reactor.listenUDP(self.config['udp_port_audio'] + 1, rtcp_client.RTCP_Client()) # RTCP
119:	            elif "cseq: "+str(3+cseq_audio) in data_ln:
120:	                # PLAY
121:	                to_send = """\
122:	PLAY rtsp://""" + self.config['ip'] + self.config['request'] + """/ RTSP/1.0\r
123:	CSeq: """ + str(4+cseq_audio) + """\r
124:	Session: """ + self.session + """\r
125:	Range: npt=0.000-\r
126:	User-Agent: Python MJPEG Client\r
127:	\r
128:	"""
129:	            elif "cseq: "+str(4+cseq_audio) in data_ln:
130:	                if debug:
131:	                    print 'PLAY'
132:	                pass
133:	                
134:	            elif "cseq: "+str(5+cseq_audio) in data_ln:
135:	                if debug:
136:	                    print 'TEARDOWN'
137:	                pass
138:	
139:	            if to_send:
140:	                self.transport.write(to_send)
141:	                if debug:
142:	                    print 'We say:\n', to_send

В случае присутсвия аудио трека, этот модуль также запускает rtp_audio_client.py и соответствующий RTCP клиент.

После успешного соединения за работу принимается rtp_mjpeg_client.py, обрабатывая входящий поток данных.
rtp_mjpeg_client.py
08:	class RTP_MJPEG_Client(DatagramProtocol):
09:	    def __init__(self, config):
10:	        self.config = config
11:	        # Previous fragment sequence number
12:	        self.prevSeq = -1
13:	        self.lost_packet = 0
14:	        # Object that deals with JPEGs
15:	        self.jpeg = rfc2435jpeg.RFC2435JPEG()
16:	
17:	    def datagramReceived(self, datagram, address):
18:	        # When we get a datagram, parse it
19:	        rtp_dg = rtp_datagram.RTPDatagram()
20:	        rtp_dg.Datagram = datagram
21:	        rtp_dg.parse()
22:	        # Check for lost packets
23:	        if self.prevSeq != -1:
24:	            if (rtp_dg.SequenceNumber != self.prevSeq + 1) and rtp_dg.SequenceNumber != 0:
25:	                self.lost_packet = 1
26:	        self.prevSeq = rtp_dg.SequenceNumber
27:	        # Handle Payload
28:	        if rtp_dg.PayloadType == 26: # JPEG compressed video
29:	            self.jpeg.Datagram = rtp_dg.Payload
30:	            self.jpeg.parse()
31:	            # Marker = 1 if we just received the last fragment
32:	            if rtp_dg.Marker:
33:	                if not self.lost_packet:
34:	                    # Obtain complete JPEG image and give it to the
35:	                    # callback function
36:	                    self.jpeg.makeJpeg()
37:	                    self.config['callback'](self.jpeg.JpegImage)
38:	                else:
39:	                    #print "RTP packet lost"
40:	                    self.lost_packet = 0
41:	                    self.jpeg.JpegPayload = ""

Он прост в понимании. Каждый раз, когда мы принимаем очередную датаграмму, мы парсим её с помощью модуля rtp_datagram.py, а результат скармливаем модулю rfc2435jpeg.py, который создаёт полноценное JPEG изображение. Далее мы ждём появления маркера rtp_dg.Marker и как он появится вызываем callback функцию с восстановленным изображением.

Парсер RTP датаграм выглядит вот так:
rtp_datagram.py
26:	    def parse(self):        
27:	        Ver_P_X_CC, M_PT, self.SequenceNumber, self.Timestamp, self.SyncSourceIdentifier = unpack('!BBHII', self.Datagram[:12])
28:	        self.Version =      (Ver_P_X_CC & 0b11000000) >> 6
29:	        self.Padding =      (Ver_P_X_CC & 0b00100000) >> 5
30:	        self.Extension =    (Ver_P_X_CC & 0b00010000) >> 4
31:	        self.CSRCCount =     Ver_P_X_CC & 0b00001111
32:	        self.Marker =       (M_PT & 0b10000000) >> 7
33:	        self.PayloadType =   M_PT & 0b01111111
34:	        i = 0
35:	        for i in range(0, self.CSRCCount, 4):
36:	            self.CSRS.append(unpack('!I', self.Datagram[12+i:16+i]))
37:	        if self.Extension:
38:	            i = self.CSRCCount * 4
39:	            (self.ExtensionHeaderID, self.ExtensionHeaderLength) = unpack('!HH', self.Datagram[12+i:16+i])
40:	            self.ExtensionHeader = self.Datagram[16+i:16+i+self.ExtensionHeaderLength]
41:	            i += 4 + self.ExtensionHeaderLength
42:	        self.Payload = self.Datagram[12+i:]

Модуль восстановления JPEG достаточно большой, так как содержит в себе несколько таблиц и довольно длинную функцию генерации заголовка. Поэтому я их здесь опущу, предоставив только функции парсинга полезной нагрузки RTP и создания окончательного JPEG изображения.
rfc2435jpeg.py
287:	    def parse(self):
288:	        HOffset = 0
289:	        LOffset = 0
290:	        # Straightforward parsing
291:	        (self.TypeSpecific,
292:	        HOffset, #3 byte offset
293:	        LOffset,
294:	        self.Type,
295:	        self.Q,
296:	        self.Width,
297:	        self.Height) = unpack('!BBHBBBB', self.Datagram[:8])
298:	        self.Offest = (HOffset << 16) + LOffset
299:	        self.Width = self.Width << 3
300:	        self.Height = self.Height << 3
301:	        
302:	        # Check if we have Restart Marker header
303:	        if 64 <= self.Type <= 127:
304:	            # TODO: make use of that header
305:	            self.RM_Header = self.Datagram[8:12]
306:	            rm_i = 4 # Make offset for JPEG Header
307:	        else:
308:	            rm_i = 0
309:	        
310:	        # Check if we have Quantinization Tables embedded into JPEG Header
311:	        # Only the first fragment will have it
312:	        if self.Q > 127 and not self.JpegPayload:
313:	            self.JpegPayload = self.Datagram[rm_i+8+132:]
314:	            QT_Header = self.Datagram[rm_i+8:rm_i+140]
315:	            (self.QT_MBZ,
316:	             self.QT_Precision,
317:	             self.QT_Length) = unpack('!BBH', QT_Header[:4])
318:	            self.QT_luma = string2list(QT_Header[4:68])
319:	            self.QT_chroma = string2list(QT_Header[68:132])
320:	        else:
321:	            self.JpegPayload += self.Datagram[rm_i+8:]
322:	        # Clear tables. Q might be dynamic.
323:	        if self.Q <= 127:
324:	            self.QT_luma = []
325:	            self.QT_chroma = []
326:	            
327:	    def makeJpeg(self):
328:	        lqt = []
329:	        cqt = []
330:	        dri = 0
331:	        # Use exsisting tables or generate ours
332:	        if self.QT_luma:
333:	            lqt=self.QT_luma
334:	            cqt=self.QT_chroma
335:	        else:
336:	            MakeTables(self.Q,lqt,cqt)        
337:	        JPEGHdr = []
338:	        # Make a complete JPEG header
339:	        MakeHeaders(JPEGHdr, self.Type, int(self.Width), int(self.Height), lqt, cqt, dri)
340:	        self.JpegHeader = list2string(JPEGHdr)
341:	        # And a complete JPEG image
342:	        self.JpegImage = self.JpegHeader + self.JpegPayload
343:	        self.JpegPayload = ''
344:	        self.JpegHeader = ''
345:	        self.Datagram = ''

Я также реализовал модуль приёма аудио данных rtp_audio_client.py, но не стал их преобразовывать в проигрываемые данные. Если кому-нибудь это будет необходимо я в этом файле сделал набросок как всё должно быть. Нужно только организовать парсинг на подобии rfc2435jpeg.py. С аудио данными легче, так как они не фрагментированны. Каждая посылка несёт в себе достаточно данных для воспроизведения. Приводить этот модуль здесь не буду, так как статья и так уж очень длинная (поскорей бы реализовали хабрафолд).

Для корректной работы нам нужно принимать и отсылать RTCP пакеты, принимаем Sender's Reports, отсылаем Receiver's Reports. Для упрощения задачи мы будем отсылать наши RR сразу после приёма SR от камеры и будем в них закладывать идеализированные данные о том, что всё хорошо.
rtcp_client.py
09:	class RTCP_Client(DatagramProtocol):
10:	    def __init__(self):
11:	        # Object that deals with RTCP datagrams
12:	        self.rtcp = rtcp_datagram.RTCPDatagram()
13:	    def datagramReceived(self, datagram, address):
14:	        # SSRC Report received
15:	        self.rtcp.Datagram = datagram
16:	        self.rtcp.parse()
17:	        # Send back our Receiver Report
18:	        # saying that everything is fine
19:	        RR = self.rtcp.generateRR()
20:	        self.transport.write(RR, address)

А вот модуль работы непосредственно с RTCP датаграмами. Он получился тоже достаточно большим.
rtcp_datagram.py
049:	    def parse(self):
050:	        # RTCP parsing is complete
051:	        # including SDES, BYE and APP
052:	        # RTCP Header
053:	        (Ver_P_RC,
054:	        PacketType,
055:	        Length) = unpack('!BBH', self.Datagram[:4])
056:	        Version = (Ver_P_RC & 0b11000000) >> 6
057:	        Padding = (Ver_P_RC & 0b00100000) >> 5
058:	        # Byte offset
059:	        off = 4
060:	        # Sender's Report
061:	        if PacketType == 200:
062:	            # Sender's information
063:	            (self.SSRC_sender,
064:	            self.NTP_TimestampH,
065:	            self.NTP_TimestampL,
066:	            self.RTP_Timestamp,
067:	            self.SenderPacketCount,
068:	            self.SenderOctetCount) = unpack('!IIIIII', self.Datagram[off: off + 24])
069:	            off += 24
070:	            ReceptionCount = Ver_P_RC & 0b00011111
071:	            if debug:
072:	                print 'SDES: SR from', str(self.SSRC_sender)
073:	            # Included Receiver Reports
074:	            self.Reports = []
075:	            i = 0
076:	            for i in range(ReceptionCount):
077:	                self.Reports.append(Report())
078:	                self.Reports[i].SSRC,
079:	                self.Reports[i].FractionLost,
080:	                self.Reports[i].CumulativeNumberOfPacketsLostH,
081:	                self.Reports[i].CumulativeNumberOfPacketsLostL,
082:	                self.Reports[i].ExtendedHighestSequenceNumberReceived,
083:	                self.Reports[i].InterarrivalJitter,
084:	                self.Reports[i].LastSR,
085:	                self.Reports[i].DelaySinceLastSR = unpack('!IBBHIIII', self.Datagram[off: off + 24])
086:	                off += 24
087:	        # Source Description (SDES)
088:	        elif PacketType == 202:
089:	            # RC now is SC
090:	            SSRCCount = Ver_P_RC & 0b00011111
091:	            self.SourceDescriptions = []
092:	            i = 0
093:	            for i in range(SSRCCount):
094:	                self.SourceDescriptions.append(SDES())
095:	                SSRC, = unpack('!I', self.Datagram[off: off + 4])
096:	                off += 4
097:	                self.SourceDescriptions[i].SSRC = SSRC
098:	                SDES_Item = -1
099:	                # Go on the list of descriptions
100:	                while SDES_Item != 0:
101:	                    SDES_Item, = unpack('!B', self.Datagram[off])
102:	                    off += 1
103:	                    if SDES_Item != 0:
104:	                        SDES_Length, = unpack('!B', self.Datagram[off])
105:	                        off += 1
106:	                        Value = self.Datagram[off: off + SDES_Length]
107:	                        off += SDES_Length
108:	                        if debug:
109:	                            print 'SDES:', SDES_Item, Value
110:	                    if SDES_Item == 1:
111:	                        self.SourceDescriptions[i].CNAME = Value
112:	                    elif SDES_Item == 2:
113:	                        self.SourceDescriptions[i].NAME = Value
114:	                    elif SDES_Item == 3:
115:	                        self.SourceDescriptions[i].EMAIL = Value
116:	                    elif SDES_Item == 4:
117:	                        self.SourceDescriptions[i].PHONE = Value
118:	                    elif SDES_Item == 5:
119:	                        self.SourceDescriptions[i].LOC = Value
120:	                    elif SDES_Item == 6:
121:	                        self.SourceDescriptions[i].TOOL = Value
122:	                    elif SDES_Item == 7:
123:	                        self.SourceDescriptions[i].NOTE = Value
124:	                    elif SDES_Item == 8:
125:	                        self.SourceDescriptions[i].PRIV = Value
126:	                        # Extra parsing for PRIV is needed
127:	                    elif SDES_Item == 0:
128:	                        # End of list. Padding to 32 bits
129:	                        while (off % 4):
130:	                            off += 1
131:	        # BYE Packet
132:	        elif PacketType == 203:
133:	            SSRCCount = Ver_P_RC & 0b00011111
134:	            i = 0
135:	            for i in range(SSRCCount):
136:	                SSRC, = unpack('!I', self.Datagram[off: off + 4])
137:	                off += 4
138:	                print 'SDES: SSRC ' + str(SSRC) + ' is saying goodbye.'
139:	        # Application specific packet
140:	        elif PacketType == 204:
141:	            Subtype = Ver_P_RC & 0b00011111
142:	            SSRC, = unpack('!I', self.Datagram[off: off + 4])
143:	            Name = self.Datagram[off + 4: off + 8]
144:	            AppData = self.Datagram[off + 8: off + Length]
145:	            print 'SDES: APP Packet "' + Name + '" from SSRC ' + str(SSRC) + '.'
146:	            off += Length
147:	        # Check if there is something else in the datagram        
148:	        if self.Datagram[off:]:
149:	            self.Datagram = self.Datagram[off:]
150:	            self.parse()
151:	    
152:	    def generateRR(self):
153:	        # Ver 2, Pad 0, RC 1
154:	        Ver_P_RC = 0b10000001
155:	        # PT 201, Length 7, SSRC 0xF00F - let it be our ID
156:	        Header = pack('!BBHI', Ver_P_RC, 201, 7, 0x0000F00F)
157:	        NTP_32 = (self.NTP_TimestampH & 0x0000FFFF) + ((self.NTP_TimestampL & 0xFFFF0000) >> 16)
158:	        # No lost packets, no delay in receiving data, RR sent right after receiving SR
159:	        # Instead of self.SenderPacketCount should be proper value
160:	        ReceiverReport = pack('!IBBHIIII', self.SSRC_sender, 0, 0, 0, self.SenderPacketCount, 1, NTP_32, 1)
161:	        return Header + ReceiverReport

Парсинг строго согласно RFC. Использую функцию unpack для конвертирования данных в численные переменные, по массиву данных перемещаюсь с помощью переменной off, которая содержит текущее смещение.

А вот и ссылка: Python MJPEG over RTSP client.

Делать версию листингов с русскими коментариями уже не было сил, так что простите если кому так не удобно.

Полезно почитать

  1. Multimedia over the Internet
  2. Список RTP профилей для аудио и видео
На этом статье конец, а кто осилил — молодец!
Tags:
Hubs:
+62
Comments 39
Comments Comments 39

Articles