
В первой своей статье «измерение расстояния до объекта и его скорости» я рассмотрел захват изображений с веб-камер через 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 — остановка вещания.
Рабочей лошадкой является другой протокол: 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 | |||||||||||||||||||||||||||||||
- V (Version): (2) версия протокола. Сейчас номер версии 2.
- P (Padding, Дополнение): (1) используется в случаях, когда RTP-пакет дополняется пустыми байтами в конце, например для алгоритмов шифрования.
- X (Extension, Расширение): (1) указывает на наличие расширенного заголовка, определяется приложением. В нашем случае это не используется.
- CC (CSRC Count): (4) содержит количество CSRC-идентификаторов. Нами тоже не используется.
- M (Marker): (1) используется на уровне приложения, в нашем случае этот бит выставляется в единицу, если RTP пакет содержит окончание JPEG кадра.
- PT (Payload Type): (7) указывает формат полезной нагрузки — передаваемых данных. Для MJPEG это 26.
- Sequence Number: (16) номер RTP пакета, используется для обнаружения потерянных пакетов.
- Timestamp (32): временная метка, в нашем случае 90000 герцовая (90000 = 1 секунда).
- SSRC (Synchronization Source): (32) идентификатор синхронизатора, как смешно бы это не звучало. Определяет источник потока.
- CSRC (Contributing Source): (32) идентификаторы дополнительных источников, используется когда у нас поток идёт с нескольких мест.
- Extension Header ID: (16) идентификатор расширения, если оно у нас есть надо знать что оно из себя представляет. В нашем случае не используется.
- Extension Header Length: (16) длинна этого заголовка в байтах.
- Extension Header (Заголовок Расширения): сам заголовок. Содержимое может быть самым разным, зависит от контекста.
- Payload (Нагрузка): полезные данные — те самые наши JPEG кадры. Фрагментированные, конечно.
Переносимся на один уровень инкапсуляции выше. Теперь стоит задача преобразовать получаемые видео данные в полноценное 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 | ||||||||||||||||||||||||||||||||
- Type-specific (Зависит от типа): (8) смысл поля зависит от реализации, в нашем случае не применяется.
- Fragment Offset (Смещение фрагмента): (24) указывает на положение текущего фрагмента кадра во всём кадре.
- Type (Тип): (8) от типа зависит как восстанавливается изображение.
- Q (Quality): (8) качество изображения.
- Width: (8) ширина кадра.
- Height: (8) и высота.
- Restart Marker header (Заголовок маркеров RST): (32) используется при декодировании JPEG, если применяются RST маркеры. Не знаю используют их камеры или нет, но я этот заголовок игнорирую. Это поле появляется только при Type от 64 до 127.
- Quantization Table Data (Таблицы квантинизации): если они присутствуют, то не нужно их отдельно вычислять. А нужны они для правильного воссоздания картинки из JPEG данных. Если эти таблицы не правильные, то изображение будет с неправильными цветами и контрастами. Таблиц должно быть две: Luma и Chroma для яркости и цветности соответственно.
- MBZ, Precision, Length: (32) параметры таблиц квантинизации, я их игнорирую, Length задаю равным 128 — две таблицы по 64 байт. В ином случае я не знаю как с ними работать.
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 | |||||||||||||||||||||||||||
- Version: (2) версия RTP.
- Padding: (1) то же самое, что и для RTP.
- SC or RC or Subtype: (5) в зависимости от типа может быть количеством источников (Sources Count) или количеством получателей (Receivers Count) включенных в отчёт получателя и источника соответсвенно. Если это APP пакет, то это поле определяет подтип такого пакета.
- Packet Type: (8) тип пакета, 201 — отчёт источника (Sender's Report SS), 202 — отчёт получателя (Receiver's Report RR), 203 — описание источников (Source Description SDES) и 204 — назначение определяется приложением (APP).
- Length: (16) размер следующих за заголовком данных, измеряется в 32 битных единицах.
На этом введение заканчивается.
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.
Делать версию листингов с русскими коментариями уже не было сил, так что простите если кому так не удобно.
