Disclaimer: статья эта написана по просьбе почтеннейшей публики. Сам я не хотел выкладывать результаты, которые недостаточно «отполированы» для передачи их широкому кругу, и вот почему.
Неверная технология может увлечь людей по неверному пути, принеся вред, превышающий её пользу. Если пользоваться аналогиями, то это поданное нищему яблочко, оказавшееся ядовитым и убившее его. Бесплатность никогда не бывает оправданием для плохого софта, в конце концов, то яблоко тоже было бесплатным. «Ядовитые решения» не только бьют по пользователю, но и обесценивают труд остальных людей, вкладывавших его в тот же проект (зачастую они работают на энтузиазме, а их награда — востребованность решения; более распространённый случай, когда человек выкладывает решение бесплатно, чтобы кто‑то в него что‑то добавил в его интересах, впрочем, тоже находится под ударом, потому что для этого всё‑таки необходима популярность, а «отравивший» его проект мимопроходил этой популярности мешает). Сейчас, пока я это набираю, я смотрю на пятую попытку поставить Firefox через новенький‑моднявенький Snap на слабом, постоянно рвущемся соединении, и мне очень хочется, чтобы апофигеты Snap'а оказались сейчас передо мной, привязанными к дереву.
Сделав двухмерную версию JPEG и глянув его обычным вьюером, я обнаружил дичайшие артефакты. Как оказалось, я неверно выбрал знаменатель, к которому нужно приводить веса гармоник. Оно вроде бы как работает — но стандарт работает «в миллиметрах», а моё поделие — «в дюймах». Если всё нижеперечисленное расползётся по проектам и приведёт к появлению двух несовместимых систем кодирования — я никогда не прощу себе, что пошёл на поводу у этой кучи плюсиков под комментариями и выложил то, что выкладываю сейчас. Пожалуйста, не подводите старпёра и добейтесь максимального реюза существующих стандартов, и не только его. «Дьявол кроется в мелочах», убить проект — проще простого, а ещё проще сделать его «ядовитым». Ну, и психологический момент — теперь я чувствую, что переложил работу на плечи желающих продолжить и дальше как будто бы можно уже и не продолжать... это тоже некоторое время сдерживало меня от написания этой статьи.
Я себе вижу продолжение как‑то так:
Обкорнать третье измерение и сделать полностью совместимый со стандартом компрессор JPEG.
Посмотреть, что в нём сделано не так, как сделано у меня.
Подумать, чем руководствовался Joint Photographic Experts Group и осознанно понять, что из этого относится к 3D‑JPEG, а что — таки нет.
Правильно выбрать способ хранения гармоник. Вероятно, учесть вылезшие за годы недостатки JPEG. Например (ни к чему не подталкиваю!) — сначала хранить все DC‑компоненты, а затем — гармоники. Если не успели подгрузить гармоники — перескакиваем на следующий чанк (длина известна из его тэга, потому что в GIF каждый чанк данных предваряется тэгом), на экране мелькнут артефакты 8×8, но это лучше, чем ничего. Повторюсь, это только «мысли вслух» (это было бы круто с JPEG «по умолчанию», без специальных костылей в виде «Progressive JPEG», но не факт, что будет круто в 3D‑JPEG).
Правильно выбрать варианты хранения цветности. Понижать ли разрешение по оси времени? Что при этом делать с наличием двух чанков яркости на чанк цветности? А может быть, хранить в одном чанке больше 8 кадров? А как в JPEG стандартизирован канал яркости — как среднее арифметическое или как визуально корректное чёрно‑белое (с учётом чувствительности глаза)?
Правильно выбрать расширения для GIF (в том числе кастомные номера для тэгов, которыми мы снабдим чанки 3D‑JPEG). Я себе это вижу как чанк таблицы Хаффмана, чанк таблицы квантования и чанки, собственно, самих данных, в тэгах которых мы не забыли упомянуть, сколько миллисекунд на кадр у нас отводится (может быть, взять совместимую с AVI простую дробь?) Если характер данных резко поменялся — снова вставляем чанки Хаффмана и/или квантования, дальше чанки данных идут соответствующие (ну, как палитру на лету меняют в обычном AniGIF).
JPEG умеет работать с 10 битами на канал, если я не ошибаюсь. Одноканальный 10-битный — это уже, в принципе, вещь относительно самодостаточная, потому что палитру из 1024 цветов уже реально сделать «непрерывно перетекающей между тонами» (в 256 мне это впихнуть не удалось). Да, только для ограниченного круга рисованной анимации с определёнными стилями, но реально. А тэг для чанка с палитрой у GIF и так уже есть готовенький.
А тащить ли вообще в GIF такую часть JPEG, как сжатие Хаффмана? У GIF уже есть своё, LZW. Как оно себя покажет на квантованных коэффициентах, в основном сводящихся к нулям?
Этот пункт я в любом случае сам бы за всю жизнь не сделал, поэтому и не собирался изначально: подобрать оптимальную таблицу квантования! К счастью, она, в лучших традициях JPEG, прямо в файле и хранится. Тут мы не делаем ничего необратимого — всегда можно улучшать сложившиеся на практике таблицы ещё и ещё.
И это только касательно GIF, к которому математика готова «здесь и сейчас». «Взрослые» кодеки могут потребовать более высокой скорости распаковки — ну, а GIF обычно имеет невысокое разрешение и предназначен для компактных, коротких видео без звука, а особенность 3D‑JPEG как раз в том, что чем выше степень сжатия, тем меньше нагрузка на процессор при распаковке (то есть компактные, сильно пережатые «анигифки» идеально подходят).
Теперь о цифрах. Я тестировал алгоритм на чёрно‑белом (о цвете вообще пока не думал) видео в 584 кадра 640×360 каждый (вполне нормальный размер для анигифки).
Сырой размер: 134 553 600.
Размер в 7-zip: 4 533 514.
Хаффман, конечно, сожмёт не так сильно, но, думаю, разительных отличий, стоящих того, чтобы LZMA туда тащить — мы не встретим. Опять же, мы ещё не видели в деле LZW.
Характер видео: различные движения как кадра целиком, так и отдельных предметов в кадре.
Таблица квантования: от фонаря, то есть доработанная напильником традиционная 2D.
Почему же столь примитивная вещь, как «MJPEG на стероидах», наступает на пятки «взрослым» кодекам? А дело в том, что косинусное преобразование по шкале времени позволяет реализовать самую сильную сторону таких кодеков, то есть выделение движущихся областей и передачу их из кадра в кадр с сохранением только тех небольших изменений, которые эти области претерпевают, плюс самого факта их движения. Основное содержимое таблицы коэффициентов по шкале времени — нули. Но только без интеллектуального анализа, всё получается «само собой», самыми примитивными средствами. Что же касается артефактов, которыми приходится платить за такое сжатие — я выбрал кадры, где движется и фон, и объект, и собрал из них две обычные гифки чудовищного веса (на одной было нагляднее, но не влезло в хабралимит). На них всё можно оценить «как есть», потому что применительно к монохромным 256-цветным изображениям обычный GIF ведь, по сути, lossless‑формат!
Боже мой, сержант, эти гифки ползут прямо на нас! Нас сейчас раздавит!!!


Ну, а теперь код, которым это было получено.
Я обещаю прочитать спецификации GIF и JPEG и подумать на шаг вперёд прежде, чем что-то делать «для широкой публики» на основе этого кода!
//Собирается OpenWatcom 1.9 как Win32 текстовое консольное приложение. #include <fstream.h> #include <math.h> #include <malloc.h> #define max(a,b) (((a) > (b)) ? (a) : (b)) #define min(a,b) (((a) < (b)) ? (a) : (b)) //double Stat2D[8][8]={0}, Stat3D[8][8][8]={0}; //int Count2D=0, Count3D=0; double CosFreq[8][8] = { 1, 1, 1, 1, 1, 1, 1, 1, 0.980785, 0.83147, 0.55557, 0.19509, -0.19509, -0.55557, -0.83147, -0.980785, 0.92388, 0.382683, -0.382683, -0.92388, -0.92388, -0.382683, 0.382683, 0.92388, 0.83147, -0.19509, -0.980785, -0.55557, 0.55557, 0.980785, 0.19509, -0.83147, 0.707107, -0.707107, -0.707107, 0.707107, 0.707107, -0.707107, -0.707107, 0.707107, 0.55557, -0.980785, 0.19509, 0.83147, -0.83147, -0.19509, 0.980785, -0.55557, 0.382683, -0.92388, 0.92388, -0.382683, -0.382683, 0.92388, -0.92388, 0.382683, 0.19509, -0.55557, 0.83147, -0.980785, 0.980785, -0.83147, 0.55557, -0.19509 }; double AlphaNorm[8] = {1.4142135623730950488016887242097, 1, 1, 1, 1, 1, 1, 1}; static double PreCalcCoeffs[512][512]; double round (double FuckinWatcomLibrary) { if (FuckinWatcomLibrary<0) return ceil (FuckinWatcomLibrary - .5); return floor (FuckinWatcomLibrary + .5); } char* ReadBMP (char *Name, int *XSize, int *YSize, void *Colormap, int Restrictions) { #pragma pack(push) #pragma pack(0) struct { short Id; long FSize, Ver, DataOffset, Unused, Width, Height; short WTF, BPP; long Compr; //0 for uncompressed, 1,2 for RLE long DataSize, XRes, YRes, PalSize, Unused2; // char Palette[256][4]; char Palette[2][4]; } BmpHeader; #pragma pack(pop) fstream f; char *Raw, *P; int line, netto, brutto; f.open (Name, ios::in|ios::binary); if (f.fail()) return NULL; f.read ((char*)&BmpHeader, sizeof (BmpHeader)); if (f.fail()) {f.close(); return NULL;} if (BmpHeader.Id!=0x4D42) {f.close(); return NULL;} //Wrong BMP id! if (BmpHeader.Compr) {f.close(); return NULL;} //Compressed files are not supported *XSize = BmpHeader.Width; *YSize = BmpHeader.Height; f.seekg(BmpHeader.DataOffset); switch (BmpHeader.BPP) { case 24: netto = BmpHeader.Width*3; brutto = (netto + 3) & ~3; Raw = (char*)malloc(BmpHeader.Height*netto); if (!Raw) {f.close(); return NULL;} for (line=0,P=Raw; line<BmpHeader.Height; line++,P+=netto) { f.read (P, netto); if (f.fail()) {f.close(); free(Raw); return NULL;} f.seekg (brutto-netto, ios::cur); } break; default: f.close(); return NULL; //ToDo } f.close(); return Raw; } /* void Get2DStat (double Flat[8][8]) { int u, v, x, y; double DCT[8][8]; for (v=0; v<8; v++) for (u=0; u<8; u++) { DCT[v][u]=0; for (y=0; y<8; y++) for (x=0; x<8; x++) DCT[v][u]+=CosFreq[u][x] * CosFreq[v][y] * (Flat[y][x] - 128.0); DCT[v][u]/=4.0; // *=sqrt(2/8)*sqrt(2/8) if (!u) DCT[v][u]/=sqrt(2.0); if (!v) DCT[v][u]/=sqrt(2.0); } for (v=0; v<8; v++) for (u=0; u<8; u++) Stat2D[v][u]+=fabs(DCT[v][u]); Count2D++; } */ void CompressCube (double Cube[8][8][8]) { int u, v, w, x, y, z; double DCT[8][8][8]; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Cube[z][y][x]-=128; for (w=0; w<8; w++) for (v=0; v<8; v++) for (u=0; u<8; u++) { DCT[w][v][u]=0; // for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) DCT[w][v][u]+=cos( (2*x+1)*u*3.1415926536/16.0 ) * cos( (2*y+1)*v*3.1415926536/16.0 ) * cos( (2*z+1)*w*3.1415926536/16.0 ) * Cube[z][y][x]; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) DCT[w][v][u]+=CosFreq[u][x] * CosFreq[v][y] * CosFreq[w][z] * Cube[z][y][x]; DCT[w][v][u]/=8.0; // *=sqrt(2/8)*sqrt(2/8)*sqrt(2/8) if (!u) DCT[w][v][u]/=sqrt(2.0); if (!v) DCT[w][v][u]/=sqrt(2.0); if (!w) DCT[w][v][u]/=sqrt(2.0); } memcpy (Cube, DCT, sizeof(double)*8*8*8); } void ExpandCube (double DCT[8][8][8]) { int u, v, w, x, y, z; double Cube[8][8][8]; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) { Cube[z][y][x]=0; for (w=0; w<8; w++) for (v=0; v<8; v++) for (u=0; u<8; u++) Cube[z][y][x] += CosFreq[u][x] * CosFreq[v][y] * CosFreq[w][z] * DCT[w][v][u] / AlphaNorm[u] / AlphaNorm[v] / AlphaNorm[w]; Cube[z][y][x]/=8.0; // *=sqrt(2/8)*sqrt(2/8)*sqrt(2/8) } for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) DCT[z][y][x]=min(255.0,max(Cube[z][y][x]+128,0.0)); } void InitFastExpand() { int u, v, w, x, y, z; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) for (w=0; w<8; w++) for (v=0; v<8; v++) for (u=0; u<8; u++) PreCalcCoeffs[w*64+v*8+u][z*64+y*8+x] = CosFreq[u][x] * CosFreq[v][y] * CosFreq[w][z] / AlphaNorm[u] / AlphaNorm[v] / AlphaNorm[w] / 8.0; } void ExpandCubeFast (double DCT[512]) { int Coeff, Pixel; double Cube[512] = {0}; for (Coeff=0; Coeff<512; Coeff++) if (DCT[Coeff]) for (Pixel=0; Pixel<512; Pixel++) Cube[Pixel] += PreCalcCoeffs[Coeff][Pixel] * DCT[Coeff]; //Still a bottleneck!!! for (Pixel=0; Pixel<512; Pixel++) DCT[Pixel]=min(255.0,max(Cube[Pixel]+128,0.0)); } void main (void) { int FrNum, XSize, YSize; char Name[]="FramXXXX.bmp"; char OutName[]="TestXXXX.raw"; char *Frame[8]={0}; int x, y, z, xx, yy; double CoeffsQ2D[8][8]={ 16, 11, 10, 16, 24, 40, 51, 61, 12, 12, 14, 19, 26, 58, 60, 55, 14, 13, 16, 24, 40, 57, 69, 56, 14, 17, 22, 29, 51, 87, 80, 62, 18, 22, 37, 56, 68,109,103, 77, 24, 35, 55, 64, 81,104,113, 92, 49, 64, 78, 87,103,121,120,101, 72, 92, 95, 98,112,100,103, 99}, CoeffFactor, //pityful attempts (until I have no actual Coeffs3D). Pixels3D[8][8][8], Waves3D[8][8][8]; fstream Log; Log.open ("Log.hex",ios::out|ios::binary); for (int i=0; i<64; i++) CoeffsQ2D[0][i]*=2; InitFastExpand(); for (FrNum=0;FrNum<10000;FrNum++) { Name[4]='0'+FrNum/1000; Name[5]='0'+FrNum/100%10; Name[6]='0'+FrNum/10%10; Name[7]='0'+FrNum%10; cout<<"Reading "<<Name<<endl; Frame[FrNum%8]=ReadBMP(Name, &XSize, &YSize, NULL, 0/*BMP_OPEN_RGB_ONLY*/); if (!Frame[FrNum%8]) {cout<<"Couldn't open!"<<endl; break;} //No more BMPs! if (XSize%8 || YSize%8) break; //Wrong size! if (FrNum%8 && (xx!=XSize || yy!=YSize)) break; //Sizes differ from first! xx=XSize; yy=YSize; if (FrNum%8 == 7) { cout<<"Encoding eight "<<XSize<<" x "<<YSize<<endl; for (yy=0; yy<YSize; yy+=8) for (xx=0; xx<XSize; xx+=8) { for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Pixels3D[z][y][x]=(Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 ]+Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 1]+Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 2])/3.0; // for (z=0; z<8; z++) Get2DStat(Pixels3D[z]); CompressCube(Pixels3D); // for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Stat3D[z][y][x]+=fabs(Pixels3D[z][y][x]); // Count3D++; for (z=0,CoeffFactor=1; z<8; z++,CoeffFactor*=1.1) for (y=0; y<8; y++) for (x=0; x<8; x++) Pixels3D[z][y][x] = round(Pixels3D[z][y][x]/(CoeffsQ2D[y][x]*CoeffFactor))+128.001; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) if ((int)(Pixels3D[z][y][x])<0 || (int)(Pixels3D[z][y][x])>255) cout<<"Quant. error: "<<Pixels3D[z][y][x]<<" is out of range!"<<endl; for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 ] = Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 1] = Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 2] = Pixels3D[z][y][x]; } cout<<"Saving integer data"<<endl; for (yy=0; yy<YSize; yy+=8) for (xx=0; xx<XSize; xx+=8) { for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Log.write (&(Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 ]), 1); } cout<<"Decoding"<<endl; for (yy=0; yy<YSize; yy+=8) for (xx=0; xx<XSize; xx+=8) { for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Pixels3D[z][y][x]=(double)( Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 ] )-128.0; for (z=0,CoeffFactor=1; z<8; z++,CoeffFactor*=1.1) for (y=0; y<8; y++) for (x=0; x<8; x++) Pixels3D[z][y][x] *= CoeffsQ2D[y][x]*CoeffFactor; // ExpandCube(Pixels3D); ExpandCubeFast(Pixels3D[0][0]); for (z=0; z<8; z++) for (y=0; y<8; y++) for (x=0; x<8; x++) Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 ] = Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 1] = Frame[z][ (yy+y)*XSize*3 + (xx+x)*3 + 2] = Pixels3D[z][y][x]; } for (z=0; z<8; z++) { OutName[4]='0'+((FrNum&~7)+z)/1000; OutName[5]='0'+((FrNum&~7)+z)/100%10; OutName[6]='0'+((FrNum&~7)+z)/10%10; OutName[7]='0'+((FrNum&~7)+z)%10; cout<<"Writing "<<OutName<<endl; fstream f; f.open(OutName, ios::out|ios::binary); f.write(Frame[z],XSize*YSize*3); f.close(); free (Frame[z]); } /* cout<<"Gathered so far (2D):"<<endl; for (y=0; y<8; y++,cout<<endl) for (x=0; x<8; x++) cout<<Stat2D[y][x]/Count2D<<" "; cout<<"Gathered so far (3D):"<<endl; for (z=0; z<8; z++,cout<<endl) for (y=0; y<8; y++,cout<<endl) for (x=0; x<8; x++) cout<<Stat3D[z][y][x]/Count3D<<" "; */ } } Log.close(); }
Также напоминаю, что в помощи народа всё ещё нуждается вот этот проект. Нужно найти хорошую таблицу частотности букв и буквосочетаний и, главное, большим количеством CUDA-карт оптимизировать цепочку — по косвенным признакам, я к оптимуму и на пушечный выстрел не подошёл.
