Декодирование картинки jy1sat
Введение
Все началось с того, что я решил подчистить неиспользуемый код в jradio. Оказалось, спутники семейства funcube шлют дополнительные 200 байт в пакете. В зависимости от спутника, там могут содержаться более детальная телеметрия или просто текстовые сообщения с земли. После небольшого исследования выяснилось, что спутник jy1sat использует их, чтобы передавать картинку! Это мгновенно заинтересовало меня, и в следующие несколько недель я не смог спать, думая о том, как её декодировать.
Дэниэл в своем блоге описал формат, в котором передаётся эта картинка. Этот формат основан на протоколе ssdv (Slow Scan Digital Video). Если вкратце, то формат позволяет передавать небольшие картинки с помощью пакетной передачи данных. Картинка (в jpeg формате) разбивается на небольшие кусочки, которые передаются друг за другом.
Структура обоих пакетов выглядит следующим образом.
Funcube пакет
Размер (байт) | Описание |
---|---|
2 | Заголовок. Содержит id спутника |
54 | Текущая телеметрия. Различные показатели спутника в текущий момент |
200 | Полезные данные. В случае jy1sat передаётся SSDV пакет |
SSDV пакет
Формат SSDV хорошо описан в официальной документации.
Название | Размер (байт) | Описание |
---|---|---|
Sync Byte | 1 | Байт синхронизации. Может присутствовать для синхронизации потока бит. Непонятно зачем есть в полезных данных jy1sat. Итак, ясно, что jy1sat передаёт картинку и полезные данные уже извлечены из внешнего funcube пакета. Можно было бы сэкономить этот байт |
Packet Type | 1 | Тип пакета. Используется для определения коррекции ошибок. Если 0x67, то последних 32х байт кода Рида-Соломона нет. Так как funcube пакет уже прошёл коррекцию ошибок, то слать их ещё раз, но в SSDV пакете смысла нет. Тут авторы молодцы - они не шлют их, а также не шлют контрольную сумму. Так как такого типа пакета нет, то они заполняют его значением 0x68 |
Callsign | 4 | Позывной в формате Base40. Опять же, полезное поле для получения SSDV пакетов напрямую с демодулятора. Однако, в данном случае ясно, что это за пакет, поэтому создатели jy1sat не отправляют это поле и экономят 4 байта. |
Image ID | 1 | ID картинки. Крайне необходимое поле для того, чтобы понять, когда закончилась одна картинка и началась другая |
Packet ID | 2 | ID пакета внутри картинки. Используется для того, чтобы понять были ли потеряны пакеты. Если пакет был потерян, то можно вместо него нарисовать чёрный прямоугольник |
Width | 1 | Ширина картинки. Измеряется в количестве MCU. Один MCU в SSDV равен 16х16 пикселей. Размеры картинок заранее неизвестны, поэтому их необходимо передавать в каждом пакете |
Height | 1 | Высота картинки. Измеряется в количестве MCU |
Flags | 1 | Крайне важные флаги. Задают: качество jpeg картинки, флаг последнего пакета в картинке, цветовую субдискретизацию. Об этом более подробно ниже в разделе декодирования |
MCU offset | 1 | Смещение первого MCU от начала данных. Так как MCU в jpeg может занимать разное количество байт, то некоторые байты могут не поместиться в один пакет и должны будут переехать в следующий. Однако, если один из пакетов потерян, то необходимо знать, где начинается первый MCU и какой у него индекс |
MCU index | 2 | Индекс первого MCU в этом пакете в общем списке MCU всей картинки. Крайне важен, чтобы определить сколько MCU было пропущено и где начать рисовать текущий |
Payload | 205 / 237 | Непосредственно данные jpeg. Закодированные последовательно MCU. В случае jy1sat размер 189 байт, так как в funcube пакете полезные данные только 200 байт |
Checksum | 4 | Контрольная сумма. В jy1sat не передаётся |
FEC | 32 | Коды Рида-Соломона. В jy1sat не передаётся |
В принципе, формат пакета достаточно простой и хорошо продуманный. Данных должно как раз хватить на то, чтобы успешно обрабатывать потерянные пакеты, и при этом не передаётся ничего лишнего.
Главное отличие, например, от формата LRPT - тут нет промежуточного пакета, который бы содержал фиксированное количество MCU. Однако, здесь это и не нужно, так как размер картинки известен в отличие от LRPT.
Формат картинки
Картинка - это abbreviated jpeg. Только, в отличие от классического abbreviated jpeg, здесь есть следующие особенности. Во-первых, совсем нет заголовка и всех таблиц. Для SSDV таблицы квантования и хаффмана заранее зафиксированы в протоколе. Во-вторых, первый MCU в каждом пакете должен быть выровнен по байту. Это прежде всего из-за того, что MCU offset обозначает количество байт. В-третьих, DC значение в первом MCU каждого пакета должно кодироваться относительно нуля. Это сделано опять же из-за того, что пакет может потеряться и, тогда потеряется смещение и нельзя будет восстановить данные.
Я взял таблицы jpeg из одной реализации. Как выяснилось, таблицы Хаффмана стандартные, поэтому я взял их из Java. Я был сильно удивлён, что они вот так вот объявлены в Java классах. Насколько я понял, JDK использует нативный код, чтобы декодировать/кодировать jpeg.
Декодирование
Склейка пакетов
Прежде, чем декодировать MCU, необходимо склеить пакеты. Как я уже писал, часть последнего MCU в пакете, может быть перенесена в следующий. Но следующий пакет может быть потерян. Всё это нужно учитывать в алгоритме склейки. У меня он получился следующим:
- Получение списка SSDV пакетов.
- Взять SSDV пакет.
- Определение того, был ли пакет потерян. Если да, то необходимо заполнить потерянные MCU чёрным цветом.
- Определение того, принадлежит ли текущий пакет к новой картинке. Если да, то нужно завершить текущую и, если не хватает пакетов, заполнить чёрным.
- Считывать DU (data unit) до тех пор, пока не завершиться текущий MCU. Количество DU зависит от цветовой субдискретизации. Об этом ниже.
- Как только завершился MCU, его можно конвертировать из YCbCr в RGB и добавлять в BufferedImage.
- Вернуться на шаг 2.
Я постарался разбить весь код декодирования на логические классы, которые соответствуют спецификации jpeg. Это очень сильно упрощает понимание процесса декодирования jpeg и его отличие от процесса склейки пакетов.
Прежде, чем переходить к декодированию jpeg, я хотел бы описать цветовую субдискретизацию и то, как она влияет на алгоритм. Вот тут Calvin Hass привёл отличную картинку о том, как это всё работает:
Каждый MCU состоит из всех трёх цветов. Каждый из этих цветов образует блок DU, которые идут друг за другом. При этом, Cb и Cr компоненты на практике не сильно влияют на восприятие картинки, поэтому их можно задать разными хитрыми способами относительно Y компонента. На картинке выше для пикселей в квадрате 2x2, значения Cb и Cr усредняются и выдаётся только одно. Итого получается, что блок Y имеет 4 блока DU каждый по 8х8 пикселей, а блоки Cb и Cr имеют только 1 блок DU по 8x8 пикселей. В таком случае цветовая субдискретизация задана как 2:1 и передаётся в поле Flags пакета SSDV. Чтобы сделать из 6 блоков DU один MCU 16x16 пикселей, я написал такой код:
for (int row = 0; row < DataUnitDecoder.PIXELS_PER_DU; row++) {
for (int col = 0; col < DataUnitDecoder.PIXELS_PER_DU; col++) {
int cbCrSourceIndex = row * DataUnitDecoder.PIXELS_PER_DU + col;
for (int subRow = 0; subRow < yComponent.getMaxRows(); subRow++) {
for (int subCol = 0; subCol < yComponent.getMaxCols(); subCol++) {
int ySourceIndex = row * DataUnitDecoder.PIXELS_PER_DU * yComponent.getMaxCols() * yComponent.getMaxRows() + subRow * DataUnitDecoder.PIXELS_PER_DU * yComponent.getMaxCols() + col * yComponent.getMaxCols() + subCol;
rgb[ySourceIndex] = convertToRgb(yComponent.getBuffer()[ySourceIndex], cbComponent.getBuffer()[cbCrSourceIndex], crComponent.getBuffer()[cbCrSourceIndex]);
}
}
}
}
В данном примере rgb[ySourceIndex]
- это массив 16x16 с RGB пикселами. Хорошая, кстати, алгоритмическая задачка для собеседования.
Декодирование jpeg
Тут никаких особенностей нет. Чтобы получить DU, необходимо:
- прочитать DC коэффициент
- потом AC коэффициенты
- выполнить zigzag преобразование
- умножить на таблицу квантования
- выполнить обратное дискретное косинусное преобразование
Единственное, что стоит учитывать, так это то, что данных может не хватить из-за окончания пакета. Поэтому не полностью прочитанный DU необходимо прочитать ещё раз, когда новые данные будут доступны после склейки пакетов.
Результат
Пару дней на обдумывание и реализацию, и вот код готов. Можно взять тестовые данные и попробовать получить картинку.
Что-то пошло не так. Явно видно, что проблемы в склейке пакетов и в том, как цвета определяются. Я попробовал убрать Cb и Cr каналы, чтобы лучше увидеть ошибку.
Первый пакет потерян, и я хорошо обрабатываю эту ситуацию. Начало картинки чёрное. Потом идёт блок и какой-то белый блок. Явно проблема в склейке пакетов. Я попробовал дебажить и нашёл ошибку в том, как выравнивается по байту первый MCU.
Далее, методом пристального вглядывания, я обнаружил, что предыдущие DC значения для каждого из компонент должны быть свои. У меня же было одно значение на все Y, Cb, Cr.
Чёрт. Эта картинка из космоса, но не космоса. Тем не менее Y канал выглядит вполне сносно и можно добавить цвета.
Выглядит офигенно! Ещё бы не эти артефакты.
Это явно проблема декодировщика. Y канал выглядел сносно, но что, если взглянуть только на Cb и Cr.
Здесь артефакты выглядят отчётливее. Если увеличить, то они выглядят так.
Соседние MCU закрашены равномерно. В то время, как проблемные MCU имеют странные коэффициенты сверху и снизу.
Прошла неделя
Всю неделю я пытался понять и отдебажить код. С одной стороны, если бы проблема была в склейке пакетов, то падали бы ошибки как раньше. Но их нет. Это значит, что склейка работает и биты правильно поступают в DU. С другой стороны, если бы проблема была в DC коэффициентах, то картинка бы выглядела совсем плохо. Но Y канал выглядел более или менее. Значит DC коэффициенты не при чём. Оставались AC коэффициенты, но там не чему было ломаться.
Так я и ходил кругами, пока не решил запустить программу jpegsnoop из под Windows виртуалки. В этой программе я открыл файл, который получил с помощью другой реализации SSDV. И включил максимально подробный вывод декодирования.
Спустя какой-то час я нашёл в чём была причина. Дело в том, что я взял таблицу квантования “как есть”. Однако, коэффициенты в этой таблице были записаны в zigzag порядке! Достаточно было изменить:
for (int i = 0; i < zigzagDct.length; i++) {
dct[i] = zigzagDct[ZIGZAG_INDEXES[i]] * getDqt(isYComponent)[i];
}
На
for (int i = 0; i < zigzagDct.length; i++) {
dct[i] = zigzagDct[ZIGZAG_INDEXES[i]] * getDqt(isYComponent)[ZIGZAG_INDEXES[i]];
}
И получить финальный результат.
Финальный Результат
Послесловие
Жаль, что из космоса я получил вполне земную картинку. Тем не менее, декодер полезен не только для того, чтобы декодировать jy1sat. Оказывается, около-лунный DSLWP-B тоже передавал в формате SSDV! А это значит, что мой декодер можно использовать, чтобы получить настоящие фотографии с орбиты луны. Как например эту.
Декодеры SSDV уже есть. Однако, я считаю, что мой лучше по нескольким причинам:
- Понятный код. Вместо простыни на 1600 строчек, мой декодер разбит на логические классы и содержит комментарии. Это очень важно для того, чтобы можно было вносить изменения и фиксить баги.
- В нём нет специфики пакетов спутников. Вместо этого, можно конвертировать пакеты спутников в стандартные SSDV пакеты и обработать их стандартным способом.
- Он написан на Java под лицензией Apache 2.0. А это значит, что его можно использовать в моём проекте r2cloud или встраивать в закрытые коммерческие решения.
- Он позволяет получить количество декодированных MCU. По этой метрике можно оценивать качество принятого сигнала.
- Он позволяет сохранять картинку в любом другом формате, а не только в jpeg. Например, можно сохранить в png с альфа каналом и вместо пропущенных кусков сделать прозрачные области.