Disclaimer: статья эта написана по просьбе почтеннейшей публики. Сам я не хотел выкладывать результаты, которые недостаточно «отполированы» для передачи их широкому кругу, и вот почему.

Неверная технология может увлечь людей по неверному пути, принеся вред, превышающий её пользу. Если пользоваться аналогиями, то это поданное нищему яблочко, оказавшееся ядовитым и убившее его. Бесплатность никогда не бывает оправданием для плохого софта, в конце концов, то яблоко тоже было бесплатным. «Ядовитые решения» не только бьют по пользователю, но и обесценивают труд остальных людей, вкладывавших его в тот же проект (зачастую они работают на энтузиазме, а их награда — востребованность решения; более распространённый случай, когда человек выкладывает решение бесплатно, чтобы кто‑то в него что‑то добавил в его интересах, впрочем, тоже находится под ударом, потому что для этого всё‑таки необходима популярность, а «отравивший» его проект мимопроходил этой популярности мешает). Сейчас, пока я это набираю, я смотрю на пятую попытку поставить Firefox через новенький‑моднявенький Snap на слабом, постоянно рвущемся соединении, и мне очень хочется, чтобы апофигеты Snap'а оказались сейчас передо мной, привязанными к дереву.

Сделав двухмерную версию JPEG и глянув его обычным вьюером, я обнаружил дичайшие артефакты. Как оказалось, я неверно выбрал знаменатель, к которому нужно приводить веса гармоник. Оно вроде бы как работает — но стандарт работает «в миллиметрах», а моё поделие — «в дюймах». Если всё нижеперечисленное расползётся по проектам и приведёт к появлению двух несовместимых систем кодирования — я никогда не прощу себе, что пошёл на поводу у этой кучи плюсиков под комментариями и выложил то, что выкладываю сейчас. Пожалуйста, не подводите старпёра и добейтесь максимального реюза существующих стандартов, и не только его. «Дьявол кроется в мелочах», убить проект — проще простого, а ещё проще сделать его «ядовитым». Ну, и психологический момент — теперь я чувствую, что переложил работу на плечи желающих продолжить и дальше как будто бы можно уже и не продолжать... это тоже некоторое время сдерживало меня от написания этой статьи.

Я себе вижу продолжение как‑то так:

  1. Обкорнать третье измерение и сделать полностью совместимый со стандартом компрессор JPEG.

  2. Посмотреть, что в нём сделано не так, как сделано у меня.

  3. Подумать, чем руководствовался Joint Photographic Experts Group и осознанно понять, что из этого относится к 3D‑JPEG, а что — таки нет.

  4. Правильно выбрать способ хранения гармоник. Вероятно, учесть вылезшие за годы недостатки JPEG. Например (ни к чему не подталкиваю!) — сначала хранить все DC‑компоненты, а затем — гармоники. Если не успели подгрузить гармоники — перескакиваем на следующий чанк (длина известна из его тэга, потому что в GIF каждый чанк данных предваряется тэгом), на экране мелькнут артефакты 8×8, но это лучше, чем ничего. Повторюсь, это только «мысли вслух» (это было бы круто с JPEG «по умолчанию», без специальных костылей в виде «Progressive JPEG», но не факт, что будет круто в 3D‑JPEG).

  5. Правильно выбрать варианты хранения цветности. Понижать ли разрешение по оси времени? Что при этом делать с наличием двух чанков яркости на чанк цветности? А может быть, хранить в одном чанке больше 8 кадров? А как в JPEG стандартизирован канал яркости — как среднее арифметическое или как визуально корректное чёрно‑белое (с учётом чувствительности глаза)?

  6. Правильно выбрать расширения для GIF (в том числе кастомные номера для тэгов, которыми мы снабдим чанки 3D‑JPEG). Я себе это вижу как чанк таблицы Хаффмана, чанк таблицы квантования и чанки, собственно, самих данных, в тэгах которых мы не забыли упомянуть, сколько миллисекунд на кадр у нас отводится (может быть, взять совместимую с AVI простую дробь?) Если характер данных резко поменялся — снова вставляем чанки Хаффмана и/или квантования, дальше чанки данных идут соответствующие (ну, как палитру на лету меняют в обычном AniGIF).

  7. JPEG умеет работать с 10 битами на канал, если я не ошибаюсь. Одноканальный 10-битный — это уже, в принципе, вещь относительно самодостаточная, потому что палитру из 1024 цветов уже реально сделать «непрерывно перетекающей между тонами» (в 256 мне это впихнуть не удалось). Да, только для ограниченного круга рисованной анимации с определёнными стилями, но реально. А тэг для чанка с палитрой у GIF и так уже есть готовенький.

  8. А тащить ли вообще в GIF такую часть JPEG, как сжатие Хаффмана? У GIF уже есть своё, LZW. Как оно себя покажет на квантованных коэффициентах, в основном сводящихся к нулям?

  9. Этот пункт я в любом случае сам бы за всю жизнь не сделал, поэтому и не собирался изначально: подобрать оптимальную таблицу квантования! К счастью, она, в лучших традициях JPEG, прямо в файле и хранится. Тут мы не делаем ничего необратимого — всегда можно улучшать сложившиеся на практике таблицы ещё и ещё.

И это только касательно GIF, к которому математика готова «здесь и сейчас». «Взрослые» кодеки могут потребовать более высокой скорости распаковки — ну, а GIF обычно имеет невысокое разрешение и предназначен для компактных, коротких видео без звука, а особенность 3D‑JPEG как раз в том, что чем выше степень сжатия, тем меньше нагрузка на процессор при распаковке (то есть компактные, сильно пережатые «анигифки» идеально подходят).

Теперь о цифрах. Я тестировал алгоритм на чёрно‑белом (о цвете вообще пока не думал) видео в 584 кадра 640×360 каждый (вполне нормальный размер для анигифки).

Сырой размер: 134 553 600.

Размер в 7-zip: 4 533 514.

Хаффман, конечно, сожмёт не так сильно, но, думаю, разительных отличий, стоящих того, чтобы LZMA туда тащить — мы не встретим. Опять же, мы ещё не видели в деле LZW.

Характер видео: различные движения как кадра целиком, так и отдельных предметов в кадре.

Таблица квантования: от фонаря, то есть доработанная напильником традиционная 2D.

Почему же столь примитивная вещь, как «MJPEG на стероидах», наступает на пятки «взрослым» кодекам? А дело в том, что косинусное преобразование по шкале времени позволяет реализовать самую сильную сторону таких кодеков, то есть выделение движущихся областей и передачу их из кадра в кадр с сохранением только тех небольших изменений, которые эти области претерпевают, плюс самого факта их движения. Основное содержимое таблицы коэффициентов по шкале времени — нули. Но только без интеллектуального анализа, всё получается «само собой», самыми примитивными средствами. Что же касается артефактов, которыми приходится платить за такое сжатие — я выбрал кадры, где движется и фон, и объект, и собрал из них две обычные гифки чудовищного веса (на одной было нагляднее, но не влезло в хабралимит). На них всё можно оценить «как есть», потому что применительно к монохромным 256-цветным изображениям обычный GIF ведь, по сути, lossless‑формат!

Боже мой, сержант, эти гифки ползут прямо на нас! Нас сейчас раздавит!!!
Оригинал
Оригинал
3D-JPEG
3D-JPEG

Ну, а теперь код, которым это было получено.

Я обещаю прочитать спецификации 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-карт оптимизировать цепочку — по косвенным признакам, я к оптимуму и на пушечный выстрел не подошёл.

Комментарии (8)


  1. Zara6502
    04.06.2024 06:03
    +6

    Жаль что совсем ничего не понял )


    1. NickDoom Автор
      04.06.2024 06:03

      Тысяча извинений :) На нормальное введение о том, как работает обычный жпег до того, как я туда вкорячил третью координату «время» и превратил картинку в видео — пороху у меня уже не хватило %)

      Получилось как бы такое продолжение для тех, кто или сам в дебри жпега погружался, или участвовал в изначальном диалоге :)

      Не самый удобный формат, но на что уж меня хватило, пардон :)


  1. domix32
    04.06.2024 06:03
    +1

    Также напоминаю, что в помощи народа всё ещё нуждается вот этот проект.

    Вангую, что вас спасут суперперестановки, но скорость их поиска сводится к сложности задачи о коммивояжёре. Учитывая какой ахтунг у вас творится в коде (включая кодировку комментариев не то cp866 не то windows-1251) врядли найдётся большое количество желающих.


    1. NickDoom Автор
      04.06.2024 06:03

      скорость их поиска сводится к сложности задачи о коммивояжёре

      Ну то есть всё равно всё на CUDA переписывать.

      Учитывая какой ахтунг у вас творится в коде

      А всё равно всё на CUDA переписывать :-D

      Не, ну если кто-то готов, я помогу этот ахтунг распутать, конечно :) я ж тут, всегда потыкаю пальцем, отКУДА доКУДА брать и как запустить :) Там кусочек-то невелик…

      Вряд ли есть нужда каждому желающему «просто посчитать» рефакторить себе свой экземпляр «считалки» самостоятельно %)


  1. pvvv
    04.06.2024 06:03
    +1

    Чтобы не ограничиваться 8 кадрами, вместо косинусного - рекурсивное преобразование Хаара в 3d, на всю длину, по количеству кадров 640×360×584.

    mjpeg2000


    1. NickDoom Автор
      04.06.2024 06:03

      Мунк_Крик.jpg


  1. ArkadiyMak
    04.06.2024 06:03
    +1

    А как в JPEG стандартизирован канал яркости — как среднее арифметическое или как визуально корректное чёрно‑белое (с учётом чувствительности глаза)?

    https://en.wikipedia.org/wiki/YCbCr#JPEG_conversion

    Я тестировал алгоритм на чёрно‑белом (о цвете вообще пока не думал) видео в 584 кадра 640×360 каждый (вполне нормальный размер для анигифки).

    Сырой размер: 134 553 600.

    Размер в 7-zip: 4 533 514.

    При 24к/c получается битрейт около 1.5Мб/c, и с Хаффманом было бы больше. Для такого разрешения, конечно, многовато. Хотелось бы увидеть больше примеров и сравнить их с другими кодеками на разных битрейтах. Никаких метрик типа PSNR и SSIM вы не считали, верно?


    1. NickDoom Автор
      04.06.2024 06:03

      Не, какие там метрики сходства, просто визуально «Оно живое! ЖИВОЕ!!!» :) Всё на стадии «оно живое!» :) Да и трудно что-то сравнивать, когда там вместо таблицы квантования — какая-то квантунская армия… только идею дискредитирую зря :(

      Вообще, конечно, радует профессиональное внимание к результатам — Ктулху услышал мои мантры :) Возможно, оно всё-таки не сгинет :)