Вы можете сказать, что один факт выбивается из этого ряда в заголовке, потому что он не так очевиден, как остальные. Еще лет 10-15 назад я бы никогда не подумал, что тут могут быть возражения, а сейчас уже и не удивляюсь, что приходится объяснять простые истины: дело в том, что планеты обладают очень большой массой, поэтому гравитация стремится придать им форму шара. Вот и все! Хотел бы на этом закончить статью и поблагодарить за внимание.

Хотел, но не вышло: как-то раз, находясь на одном из этих массивных шаров, я оставил комментарий и получил на него 2 ответа.

По сути @Vitalley и @Covert правы. Но насколько? Действительно ли требуется хранить в double? Давайте разбираться на примере JPEG почему он портит изображение и можно ли, хотя бы теоретически, избежать потерь. Впрочем, интерес представляет даже не сам JPEG, а возможность использования линейных преобразований для сжатия без потерь.

Сначала мы рассмотрим основные этапы JPEG и немного углубимся в некоторые из них, чтобы понять происходящие преобразования с математической точки зрения. Выясним когда именно происходит искажение информации. Покажем, что информация восстановима, если задать относительно низкий шаг квантования и найдем его максимальное теоретическое значение. Затем, для проверки, постараемся найти его практически. В завершение сделаем выводы о том, насколько реальна поставленная цель.

На следующей картинке я отметил все этапы на которых происходит потеря информации.

Все рассмотрим подробно кроме кодирования Хаффмана, так как это сжатие без потерь.

Преобразование RGB в YCbCr

По стандарту, кодер не занимается преобразованием цветовых пространств. Ему на вход подается изображение, уже разделенное на каналы. Для заданных каналов кодер выполнят субдискретизацию. Это означает, что вместо хранения цвета каждого пикселя небольшой группы (размером 2x2, 2x2, 4x2 и т.п.) будет храниться только одно — среднее этой группы. На практике почти всегда используется пространство YCbCr с последующей субдискретизацией. Почему? Хорошо, что я не в школе, и могу просто скопировать из Википедии:

Перевод в систему Y’CbCr позволяет передать информацию о яркости с полным разрешением, а для цветоразностных компонент произвести субдискретизацию, то есть выборку с уменьшением числа передаваемых элементов изображения, так как человеческий глаз менее чувствителен к перепадам цвета.

Так как при субдискретизации исходные значения теряются безвозвратно, то нам придется отказаться от ее.

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

Пространства RGB и YCbCr выглядят так относительно друг друга:

Все цветовые каналы (R, G, B, Y, Cb, Cr) в неэкзотических вариациях JPEG являются 8-битными. Количество возможных цветов совпадает в обоих пространствах (2^24). Но если у большинства комбинаций (Y, Cb, Cr) нет соответствующей (R, G, B)-пары, значит у кого-то их две :( И даже больше. Однозначное восстановление с такими исходными данными невозможно.

Итак, перевод (R, G, B) в (Y, Cb, Cr):

\begin{cases}      Y = 0 + 0.299R +0.587G +0.114B \\      Cb = 128 - 0.168736R -0.331264G +0.5B \\      Cr = 128 + 0.5R -0.418688G -0.081312B      \end{cases}

В матричном виде:

\begin{pmatrix} Y \\ Cb \\ Cr \end{pmatrix} =       \begin{pmatrix} 0 \\ 128 \\ 128 \end{pmatrix} +        \begin{pmatrix} 0.299 & 0.587 & 0.114 \\ -0.168736 & -0.331264 & 0.5 \\ 0.5 & -0.418688 & -0.081312 \end{pmatrix}       \begin{pmatrix} R \\ G \\ B \end{pmatrix}

Матричное представление будет использоваться и дальше, поэтому для понимания статьи желательно знать о матричном произведении, обратной матрице и т. п.

DCT

Каждый блок 8x8 каждого канала (обычно это Y, Cb, Cr) изображения подвергается двумерному дискретному косинусному преобразованию 2-го типа. Почему используется именно DCT я подробно описывал здесь. Рассмотрим, что оно означает.

Каждый блок может быть представлен в виде матрицы:

\begin{pmatrix}       x_{00} & x_{01} & \dotsc  & x_{07}\\       x_{10} & x_{11} &  & x_{17}\\       \vdots  &  & \ddots  & \\       x_{70} & x_{71} &  & x_{77}       \end{pmatrix}

Сначала для каждого столбца независимо от других выполняется преобразование X^{(j)} \rightarrow Y^{(j)}:

\begin{pmatrix}  & x_{0j} & \\ \dotsc  & x_{1j} & \dotsc \\  & \vdots  & \\  & x_{7j} &  \end{pmatrix}\rightarrow \begin{pmatrix}  & y_{0j} & \\ \dotsc  & y_{1j} & \dotsc \\  & \vdots  & \\  & y_{7j} &  \end{pmatrix}

По следующей формуле, в которой j — индекс столбца:

y_{ij} =\frac{1}{2} C_{i}\sum _{k=0}^{7} x_{kj}\cos\left(\frac{( 2k+1) i\pi }{16}\right), где \begin{cases}       C_{i} =1/\sqrt{2} \ для\ i=0\\       C_{i} =1\ для\ остальных       \end{cases}

Такая формула может быть переписана в матричной форме: Y^{(j)}=D_8 X^{(j)}. Где X^{(j)} и Y^{(j)} — вектор-столбцы, а значения матрицы D_8 аккуратно рассчитаны по вышеприведенной формуле:

D_8=\frac{1}{2}\begin{pmatrix}       \frac{1}{\sqrt{2}} & \frac{1}{\sqrt{2}} & \dotsc  & \frac{1}{\sqrt{2}}\\       \cos\left(\frac{\pi }{16}\right) & \cos\left(\frac{3\pi }{16}\right) &  & \cos\left(\frac{15\pi }{16}\right)\\       \vdots  &  & \ddots  & \\       \cos\left(\frac{7\pi }{16}\right) & \cos\left(\frac{3*7\pi }{16}\right) &  & \cos\left(\frac{15*7\pi }{16}\right)       \end{pmatrix}

Преобразование D_8 X^{(j)} описывает переход новому базису. Каждая строка D_8 задает координаты базисного вектора. Вот они:

Одна из фичей этой матрицы — ортогональность. Это означает, что все базисные вектора ортогональны друг другу и их длины равны 1. То есть, такое преобразование описывает поворот плюс, возможно, зеркальное отражение. Это сохраняет евклидову норму векторов (то есть их длины, которые мы вычисляем по теореме Пифагора).

Вместо одного вектор-столбца в D_8 X^{(j)} можно подставить сразу все столбцы, и тогда получим: Y=D_8 X.

Далее нужно выполнить такое же преобразование Y, но уже для строк. Нам известно как это провернуть для столбцов, поэтому транспонируем Y, преобразуем, а затем транспонируем еще раз: Z=(D_8 Y^T)^T=YD_8 ^T=D_8 X D_8^T.

Сейчас провернем такой трюк. Получившуюся матрицу Z векторизуем, то есть последовательно выпишем все столбцы в один. И затем применим формулу vec(ABC)=(C^T \otimes A) vec(B), где \otimesпроизведение Кронекера:

vec(Z)=vec(D_8 X D_8^T)=(D_8 \otimes D_8)vec(X) = (D_{8 \times 8}) vec(X)

Матрица D_{8 \times 8} = D_8 \otimes D_8 имеет размер 64 на 64. Каждая ее строка представляет 64 комбинации произведений попарных значений двух «волн» из предущей картинки. И если их выписать в виде матрицы 8x8, то получится двухмерная волна. Разные строки дадут разные частоты по x и y. Вот они все на рисунке.

Описанное преобразование можно представить как нахождение корреляции блока изображения с каждой из этих волн.

Одно из свойств произведения Кронекера — если A и B ортогональные матрицы, то A \otimes B тоже является ортогональной. Формулу обратного преобразования можно получить умножив обе части на транспонированную матрицу: (D^T_{8 \times 8})vec(Z)=(D^T_{8 \times 8})(D_{8 \times 8}) vec(X). Так как для ортогональных матриц справедливо, что Q^T Q=I, то получаем: vec(X) = (D^T_{8 \times 8})vec(Z).

Почему происходят потери?

Меня давно преследует идея создания максимально гибкого JPEG-кодека, позволяющего пользователю задавать свои таблицы Хаффмана и квантования. Во-первых, потому, что это офигенно, вот почему. Во-вторых, такой кодек сможет имитировать другие кодеки (и фотоаппаратов тоже), но это отдельная тема. В-третьих, особенно если выйти за пределы стандарта, это полезно для исследований. В частности, можно предусмотреть изменение фиксированного размера блоков 8 на 8. Можно сделать даже 2 на 1. Это бесполезно с практической точки зрения, но зато каждый такой блок из двух пикселей (одного канала) можно наглядно представить в виде вектора координатной плоскости и наглядно увидеть преобразования этого вектора.

DCT для такого простого случая будет выглядеть так:

Y = \frac{\sqrt{2}}{2} \begin{pmatrix} 1 & 1\\ 1 & -1 \end{pmatrix} X \ или \begin{cases}      y_{0} =\frac{\sqrt{2}}{2} (x_0 + x_1)\\      y_{1} =\frac{\sqrt{2}}{2} (x_0 - x_1)      \end{cases}

Представим, что первый пиксель имеет цвет 3, а второй — 2. После преобразования получим (3.536, 0.707). Наименьший шаг квантования в JPEG равен 1, то есть это просто округление: (4, 1). После восстановления: (3.536, 2.121), значит первый пиксель — 4, второй — 2.

Если бы мы квантовали с большим шагом чем 1, то расхождение могло оказаться еще значительнее. Относительно недавно я рассматривал, что происходит при различных шагах квантования. Но тогда все они были целыми, не меньше единицы, а сейчас наоборот, будем уменьшать.

Глядя на координатную сетку на рисунке выше, можно сделать грубую прикидку. Квантованная точка не должна отдалятся более чем на 0.5 от исходной. Но если исходная точка оказывается в центре ячейки сетки пространства (y_0,y_1), квантованная отдаляется на половину диагонали квадрата (0.707). Значит, если квантовать с более мелким шагом не превышающим 0.5/0.707 = 0.707, то потерь удастся избежать. На следующем рисунке показан шаг 0.65 (при 0.707 узлы сеток совпадают и получается не наглядно) для нескольких исходных точек.

Упрощенный код примитивного кодека:

step = 0.65
C = sqrt(2) / 2

def write(x0, x1):
    y0 = C * (x0 + x1)
    y1 = C * (x0 - x1)
    save_to_file(round(y0 / step))
    save_to_file(round(y1 / step))    

def read():
    y0 = load_from_file() * step
    y1 = load_from_file() * step    
    x0 = round(C * (y0 + y1))
    x1 = round(C * (y0 - y1))
    return x0, x1
    
x = (3, 2)    
write(*x)
xx = read()
assert xx == x

Разумеется, уменьшение шага не дается бесплатно. Чем он меньше, тем больше множество значений, которые нужно хранить. Уменьшение в два раза эквивалентно увеличению на один бит.

В настоящем JPEG используется 64-мерное пространство, поэтому половина диагонали 64-мерного гиперкуба равна \sqrt{64*1^2}/2=4. Получается, что нужно сделать шаг квантования 0.125, то есть хранить 3 бита после запятой. Но это не доказательство, конечно, а просто прикидка.

Оказалось, что это верная оценка для произвольного 64-мерного ортогонального преобразования. Но для DCT максимальный шаг квантования немного больше.

Максимальный шаг квантования

Итак, пусть X — исходный вектор с целыми значениями, D — матрица преобразования (RGB в YCbCr или DCT), q — функция квантования, r — округления. После квантования получаем вектор q(DX). Для DCT именно эти значения сохраняются в файле. При восстановлении исходных значений X выполняем обратное преобразование: r(D^{-1}q(DX)).

Наша задача — научиться восстанавливать без потерь, то есть сделать справедливым равенство r(D^{-1}q(DX))=X для любого вектора X. Единственное «неизвестное» в этом уравнении — функция квантования. Очевидно, что можно не квантовать (то есть задать q(A)=A) и тогда, учитывая D^{-1} D=I, уравнение схлопнется до r(X)=X, а значения X и так целые. Формально, это решение, но оно, очевидно, не подходит.

Что вообще следует из записи r(A)=B? То, что ни один элемент вектора A не отличается от соответствующего элемента B более чем на 0.5. Или эквивалентно: максимальная абсолютная разность элементов A и B не больше 0.5:

max(|a_0-b_0|, |a_1-b_1|, \dots, |a_{n-1}-b_{n-1}|) \leq 0.5

Хмм, да это же кисулькен норма (ну или расстояние) Чебышева.

Кстати, в чем различие между нормой и расстоянием?

Расстояние — более широкое понятие. Если у нас задана норма, то через нее мы можем определить расстояние: d(x,y)=\| x-y \|, но обратное не всегда верно. Так, если задать такое расстояние (называемое дискретным) d(x,x)=0 и d(x,y)=1 для x \neq y, то для любой из норм нарушается свойство однородности \| \alpha x \| = | \alpha | \| x \|. Действительно, для x \neq y получаем: 1=d(2x,2y)=\| 2x - 2y \| = 2 \| x-y \|=2 d(x,y) = 2. Но для всех популярных расстояний определена и норма.

Получается, что из r(D^{-1}q(DX))=X следует \| D^{-1}q(DX)-X \|_\infty \leq 0.5. Умножим выражение под нормой на D^{-1} D (мы можем так поступить потому что это равносильно умножению на единичную матрицу, то есть результат не меняется):

D^{-1}q(DX)-X = D^{-1} D(D^{-1}q(DX)-X) = D^{-1} (q(DX) - DX)

Обозначим L=q(DX) - DX. Вектор L представляет отклонение при квантовании в преобразованном базисе. Итак, наше неравенство приобретает следующий вид:

\| D^{-1} L \|_\infty \leq 0.5

Это должно выполняться для любого L. А каким он может быть? Очевидно, разность между квантованным и исходным значением не превышает половины шага квантования. Так, если шаг квантования равен s, то \| L \|_\infty \leq s/2. В наших интересах найти наибольший шаг, удовлетворяющий \| D^{-1} L \|_\infty \leq 0.5. На следующем рисунке показан максимальный допустимый шаг.

Уменьшать шаг неэффективно. Но увеличение, даже совсем чуть-чуть, может привести к нарушению нашего основного неравенства.

Здесь мы можем выполнить следующую грубую прикидку для ортогонального преобразования. Евклидова норма вектора не меньше нормы Чебышева. Поэтому, если справедливо \| D^{-1} L \|_2 \leq 0.5, то будет верно и \| D^{-1} L \|_\infty \leq 0.5. А так как ортогональное преобразование сохраняет евклидову норму, то \| L \|_2 \leq 0.5. На рисунке видно, что при таком подходе шаг квантования уменьшился из-за того, что мы избавились от заданного базиса и перешли к произвольному ортогональному.

Воспользуемся обеими нормами:

\| L \|_2 = \sqrt{x_0^2 + \dots + x_{N-1}^2} \leq \sqrt{N * \left(\frac{s}{2}\right)^{2}} \leq \frac{1}{2}

Отсюда максимальный шаг равный 1/\sqrt N. Для N=64 получим 0.125, как и предполагали в начале статьи.

Можно точнее? Да. Возвращаемся к \| D^{-1} L \|_\infty \leq 0.5 и запишем его так:

\underset{i}{\max}\left( | D_{i}^{-1} L\right | ) =\underset{i}{\max}\left( | D_{i,0}^{-1} L_{0} +D_{i,1}^{-1} L_{1} +\dotsc +D_{i,N-1}^{-1} L_{N-1} | \right) \leq 0.5

Чтобы максимизировать сумму, нужно максимизировать каждое слагаемое. Для этого нужно в качестве L_j подставить s/2 с тем же знаком, что и у D^{-1}_{i,j} (не тот знак приведет к вычитанию). Или же подставить плюс s/2 и взять D^{-1}_{i,j} по модулю:

s * \underset{i}{max}\left( | D_{i,0}^{-1} | + \dotsc + | D_{i,N-1}^{-1} | \right ) \leq 1

Считаем:

import numpy as np
from scipy.fftpack import dct

def max_step(D):
    return 1 / np.max(np.sum(np.abs(D), axis=1))
  
YCbCr2RGB = [[1, 0, 1.402], [1,-0.344136,-0.714136], [1,1.772,0]]

# Генерируем матрицу для одномерного DCT
# Вместо X в формуле dct(X) = DX подставляем единичную матрицу
# И получаем D = dct(I)
D_8 = dct(np.identity(8), norm='ortho', axis=0)

# Оператор Кронекера
D_8x8 = np.kron(D_8, D_8)

# Для DCT обратная матрица равна транспонированной
ID_8x8 = D_8x8.T

print('YCbCr2RGB:', max_step(YCbCr2RGB)) # 0.36075
print('IDCT:', max_step(ID_8x8))         # 0.14328

C YCbCr понятно, 1 / (1 + 1.772) = 0.36075. Теперь давайте вычислять 0.14328 для DCT. Заметим, что строки матрицы D^{-1}_{8\times 8} — это столбцы D_{8 \times  8}. Если представить индекс столбца t в виде t=8p+q, 0\leq p\leq 7, 0\leq q\leq 7:

\sum _{i=0}^{63} | ( D\otimes D)_{i, t} | =\sum _{i=0}^{7} \sum _{j=0}^{7} | D_{i,p} D_{j,q} | =\sum _{j=0}^{7} | D_{i,p} | *\sum _{j=0}^{7} | D_{j,q} |

Тогда, например, для нулевого столбца:

\left(\sum _{i=0}^{7} | D_{i,0} | \right)^{2} =\left(\frac{1}{2}\left(\frac{1}{\sqrt{2}} +\sum _{i=1}^{7}\cos\frac{i\pi }{16}\right)\right)^{2}

Это выражение можно упростить с помощью тождества Лагранжа:

\sum_{k=0}^n \cos k\theta = \frac{\sin \tfrac12\theta + \sin\left(\left(n + \tfrac12\right)\theta\right)}{2\sin\tfrac12\theta} \Rightarrow \sum_{k=1}^n \cos k\theta = \frac{1}{2} \left(    \frac{\sin\left(\left(n + \tfrac12\right)\theta\right)}{\sin\tfrac12\theta}  -1\right)

Подставляем и немного упрощаем. Итого: \frac{1}{16} \left( \cot\frac{\pi}{32} + \sqrt2 - 1  \right)^2 \approx 6,97935. В принципе, котангенс тоже можно вычислить точно, но с кучей радикалов.

Максимальный шаг: 1/6,97935 \approx 0.14328. Такое же значение получается и для других столбцов матрицы.

Проверяем 0.36075

Выполнить проверку максимального отклонения при преобразовании цветовых пространств можно просто путем перебора всех значений (R, G, B):

M = np.array([[ 0.299,     0.587,     0.114   ],
              [-0.168736, -0.331264,  0.5     ],
              [ 0.5,      -0.418688, -0.081312]])
IM = np.array([[1,  0,         1.402   ],
               [1, -0.344136, -0.714136],
               [1,  1.772,     0       ]])

def quant(x, step):
    return np.round(x / step) * step

def ycbcr_d_inv(x, step):
    y = np.dot(IM, quant(np.dot(M, x), step))
    return np.max(np.abs(y-x))

step = 0.36075
max_d = 0
for r in np.arange(0,256):
    for g in np.arange(0,256):
        for b in np.arange(0,256):
            d = ycbcr_d_inv([r,g,b], step)
            if d > max_d:
                max_d = d

print(max_d) # 0.499517

Действительно, выбранный шаг почти идеален. Наибольшее отклонение очень близко к 0.5, но не превысило его, поэтому при округлении мы восстановим исходные значения.

Проверяем 0.14328

Представьте квадратный город километр на километр. У него есть центральный район размером пол- на полкилометра, и окраина, площадь которой равна половине площади всего города — 0.5 кв. км. Эти районы двухмерного города изображены на рисунке.

Площадь центра равна 0.25, а ширина окраины — примерно 0.146. Но что если город представляет собой гиперкуб? Тогда его объем — 1 км^N, а остальные значения для некоторых размерностей в таблице:

       N  Объем центра (км^N)  Ширина окраины (метров)
       1      0.5                  250 
       2      0.25                 146
       3      0.125                103
       4      0.0625                80
       8      0.0039                41
      16      1.5e-05               21
      32      2.3e-10               11
      64      5.4e-20                5

В 64-мерном городе центр стал занимать невообразимо маленькую часть всего города, несмотря на его большой линейный размер. А половина горожан живет в тонкой 5-метровой оболочке на окраине.

Но окраина очень неоднородна. Где-то до центра всего 500 метров, но в среднем подальше. Дальше всего до одного из 264 углов. По прямой из центра не очень далеко — 4 километра. Но если застройка (гипер-)прямоугольная и передвигаться можно только по улицам параллельным осям, то придется преодолеть 32 километра. Но окрестности углов занимают мизерную долю. Даже если предположить, что их размер 333 метра, то в двухмерном городе они займут 4/9 площади, а 64-мерном всего 5.37 × 10^{−12}.

Эти контринтуитивные размышления — частные случаи проявления проклятия размерности. Для нас они интересны тем, мы хотим найти наибольшее отклонение при квантовании, а для этого нужно чтобы узел сетки квантования оказался недалеко от одного их углов гиперкуба. Наверно это можно сделать аналитически, но тут мои полномочия всё. Мы можем перебирать различные векторы и надеяться на удачу, которой не будет. Потому что вектор отклонения будет вести себя примерно как случайно распределенная величина. Посмотрим сначала на распределения расстояний от случайной точки до центра куба для разных размерностей (в логарифмическом масштабе):

def get_data(n):
    data = []
    for _ in np.arange(0, 1000000):
        x = np.random.uniform(-0.5, 0.5, size=(n))
        data.append(np.linalg.norm(x))
    return data

Теперь перейдем к распределению отклонений DCT. Здесь считается расстояние Чебышева, для «наклоненного» гиперкуба, поэтому распределение немного другое. Это можно можно приблизительно проиллюстрировать на плоскости:

def quant(x, step):
    return np.round(x / step) * step

def dct2d_d_inf(x, step):
    y = np.dot(ID_8x8, quant(np.dot(D_8x8, x), step))
    return np.max(np.abs(x-y))

step = 0.14328
data = []
for _ in np.arange(0, 1000000):
    # В Jpeg из Y, Cb, Cr вычитается 128
    x = np.random.randint(256, size=(64)) - 128
    data.append(dct2d_d_inf(x, step))

plt.clf()
plt.xlim(0, 0.5)
plt.hist(data, 30, log=True)
plt.show()

Наибольшее значение, которое я смог найти — 0.318618 для вектора [79192, 0, 0, ..., 0]. Но исходными значениями для DCT в Jpeg являются Y, Cb, Cr уменьшенные на 128, то есть от -128 до 127. И для них все перебранные отклонения оказались меньше 0.25.

Выводы

Наш проект «JPEG без потерь» потребует серьезных жертв:

  • Не использовать субдискретизацию.

  • Квантовать значения Y, Cb, Cr с шагом 0.36075. Это плюс 2 бита.

  • Квантовать коэффициенты DCT с шагом 0.14328. Плюс еще 3 бита.

Итого к исходным 8 битам добавляется еще 5, итого 13. Любопытно, что стандарт помимо обычного 8-битного JPEG , также описывает и 12-битный. Раз уж мы зашли так далеко, то интересна сама принципиальная возможность использовать его для нашей цели. Но нужно где-то сэкономить 1 бит. Способы такие:

  • Самый разумный — кодировать сразу в RGB, так как без субдискретизации и таким квантованием оно бессмысленно.

  • Не округлять Y, Cb, Cr, тогда шаг квантования составит 0.36075 * 0.14328 = 0,05169. Это \log_2(1/0,05169)=4.274 бита. Ну, почти «запихнули».

  • Надеяться никогда не встретить отклонение превышающее 0.5 при квантовании DCT-коэффициентов с шагом 0.25.

Поддержка 12-битного JPEG встречается крайне редко. Я нашел только libjpeg и один платный конвертер (в бесплатной версии ставит вотермарку — для тестов пойдет).

Разумеется, все эти выводы неприменимы на практике. Даже обычный JPEG с максимально высоким качеством почти не используется, так как теряются основные фичи алгоритма, что приводит к слишком большому размеру файлов. А для 12 битов размер увеличивается в 1.5 — 3 раза и уже проигрывает png. Впрочем, эти выводы могут быть полезны при разработке алгоритма сжатия без потерь, основанные на DCT.

Напоследок, краткие правила для минимизации потерь:

  1. Не используйте JPEG.

  2. Если используете — сохраняйте в нем только окончательный вариант, не пересохраняйте.

  3. Если пересохраняете — выбирайте то же самое качество сжатия, не уменьшая и не увеличивая.

  4. Если пересохраняете и изменяете качество сжатия — не изменяйте размер изображения.

  5. Если пересохраняете и изменяете размер — не удивляйтесь.

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


  1. steanlab
    12.12.2022 14:12
    +10

    Не статья, а картина.
    Спасибо


    1. Fil Автор
      12.12.2022 14:17
      +9

      Спасибо! Да, на картины ушло больше всего времени :)


  1. masai
    12.12.2022 15:23
    +4

    Не используйте JPEG

    Кстати, воспользуюсь случаем и порекламирую JPEG XL, который хоть и появился только недавно, но по совокупности особенностей, наверное, лучший формат сжатия.

    Он поддерживает перекодирование существующих JPEG без потерь, но размер при этом уменьшается на 20—30 %.

    Но и без этого формат очень перспективный (высокая степень сжатия, большая скорость, многоканальность, поддержка HDR, анимация и так далее). С нетерпением жду его более широкой поддержки.


    1. TheChief5055
      12.12.2022 15:52
      +1

      Настолько перспективный, что гугель решил выкинуть его из Хрома на мороз.


      1. ElvenSailor
        12.12.2022 18:19
        +14

        у него был фатальный недостаток: его писали не они.

        googl маму сбросит с поезда бортанёт что угодно ради своего WebP.


      1. masai
        13.12.2022 10:56

        Начнём с того, что до выкидывания они его всё же в браузер добавили. Да и они когда выкидывали, не писали же, что формат плохой. Мотивировали тем, что у сообщества, якобы слишком малый интерес. Хотя откуда интересу взяться, если браузер не поддерживает? Ну и так случайно совпало, что гугл разрабатывает WebP 2.

        Но даже если Chrome поддерживать не будет, формат от этого бесполезным не становится. Картинки не только в браузерах есть. Я на работе занимаюсь цифровой обработкой изображений (среди прочего) и этот формат мне подходит идеально (особенно радует многоканальность). Поддержка софта подтянется через какое-то время, и домашние фото тоже сконвертирую.


      1. eugeny54
        13.12.2022 16:29
        +3

        Запасаемся попкорном (ну или скрещиваем пальцы, кому как нравится) - https://chromium-review.googlesource.com/c/chromium/src/+/4095497 . Индустрия - "ЗА" использование JPEG-XL - https://bugs.chromium.org/p/chromium/issues/detail?id=1178058#c189 .


    1. MagisterAlexandr
      12.12.2022 18:00

      Он поддерживает перекодирование существующих JPEG без потерь

      Совсем-совсем без потерь?


      1. Melirius
        12.12.2022 23:34
        +5

        Совсем. Те же коэффициенты пережимает другим, лучшим сжатием без потерь.

        Есть и ещё несколько проектов, которые это с JPEG делают, навскидку Lepton и Brotli.


    1. Denai
      12.12.2022 18:03
      +1

      1. masai
        13.12.2022 10:42

        AVIF значительно медленнее, качество сжатия для типичных режимов хуже, есть ограничения на размер изображения, хуже поддержка HDR. Многие особенности и вовсе не поддерживаются, так как это, фактически, часть видеокодека, и AVIF никогда не проектировался как формат для изображений. Ну и без потерь свою коллекцию фотографий не сконвертировать.

        Из плюсов: лучшая поддержка браузерами (но это не совсем свойство формата) и немногим лучшее качество при очень сильных степенях сжатия.


    1. Didimus
      13.12.2022 17:39

      Осталось найти софт, который безбажно сконвертирует все картинки в хранилище в этот новый формат. И, желательно, не только под вин-11


      1. masai
        14.12.2022 13:10

        Официальный cjxl умеет. Но это в командной строке, не всем будет удобно.

        Формат новый, так что нужно подождать, пока поддержка не станет более широкой.


        1. Didimus
          14.12.2022 15:59

          Поддержку форматов от эппл уже сколько лет ждём..


  1. panteleymonov
    12.12.2022 19:40

    Шо? Никогда такого не было и вот опять?

    Заходим, забиваем двойками матрицу квантования.

    defaultQuantMatrix
    defaultQuantMatrix = [
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2,
     2, 2, 2, 2, 2, 2, 2, 2
    ]

    Смотрим результат

    Видим что байтовой точности вполне хватает для сохранения без потерь. Ну, а если зайти в документацию по JPEG то там используется табличные значения синуса, плюс вычисления в целочисленной форме с фиксированной точкой, что избавляет от погрешности в единицу совсем.


    1. gchebanov
      12.12.2022 20:42

      сохранения без потерь

      Во первых почему двойками а не единицами? Во вторых у вас потери в вашем же скриншоте -- целых 18 штук (из 64 возможных). В прочем на этом же примере с единицами (т.е один байт на входе - один на выходе) остаются 5 ошибок.


      1. al_pi
        12.12.2022 21:48

        Двойки просто для аналитики и наглядности не больше, можете хоть 0.5 написать в погоне за точностью все равно будут неточности. Погрешность один бит на компоненту (разница между изображением оригинальным и фильтрованным через DCT), которую можно решить используя более точную таблицу для DCT как я описал раньше.

        можно убрать второе округление (оно там лишнее) и получить без погрешности, но таблицу dct лучше все же брать из документации.

        dequant = (v,i) -> (v * quantMatrix[i])
        результат

        Справедливости ради, в jpeg используется минимум 16 битная точность для расчета DCT квантизации и последующей упаковки Хаффмана, не зависимо от входящих 8 битных компонент (по крайней мере в кодеках 90-x). Благодаря тому же Хаффману и DCT (без RLE Для нулей) объем информации не вылезет дальше оригинального размера файла, это уже говорит что в среднем байта хватает. Кроме этого в оригинальном формате есть теги описывающие начало блока 8x8, а это лишняя информация, которая помогает восстановить повреждение файла.

        Ну и главное не будем изобретать велосипед: https://ru.wikipedia.org/wiki/JPEG-LS


      1. al_pi
        13.12.2022 09:35

        Еще один пример и пища для размышления. Если взять не 64 значения, а всего лишь два, и считать для них своеобразный аналог DCT то упремся в ту же проблему. Коэффициенты будут получаться в диапазоне +127.5 -128.5, что потребует на 1 бит больше для их хранения. В то же время эта пара коэффициентов будет взаимосвязана по величинам как единичный вектор. То есть чем больше значение одного из коэффициентов тем меньше возможный диапазон другого, это автоматом сокращает несущую часть битов, и благодаря тому же Хаффману возвращает к максимальным 8 битам на значение.

        Также нужно не упустить пример где эти два коэффициента считаются несмотря на переполнения байта. Средний с отсечением дробной части, а разница с отсечением старшего бита. Тут также возможно восстановить значения без погрешности используя те же 8 бит. Отмасштабировать это на 64 коэффициента будет проблематично, но сделать преобразование yuv без потери и лишних бит будет возможно. Конечно, это будет не тоже самое.


  1. gchebanov
    12.12.2022 20:02
    +3

    И для них все перебранные отклонения оказались меньше 0.25.

    Плохо искали, жадный покоординатный перебор за секунду нашел 0.259, а за две минуты 0.31.
    Вот вектор.

    Никак не могу сообразить как искать оптимальнее, не подскажите? Вроде есть какой-то крутой оптимизационный алгоритм (~наименьшей привязки, или что-то типа того) на сетках, но он про чуть другую функцию

    def q_error(A, x, step):
        t = np.dot(A, x)
        y = quant(t, step)
        return np.max(np.abs(y-t))
    


    1. Fil Автор
      12.12.2022 20:53

      Во, я ждал такой коммент, спасибо! Можете сказать в двух словах, как именно вы искали?


      1. gchebanov
        12.12.2022 21:23
        +1

        Берем 256^2 случайных векторов, из них оставляем лучший по функции.
        Перебираем координату (0..63), перебираем все возможные значения (-128..127), считаем функцию, если улучшилось - улучшаем.
        Начинаем заново, делаем так 256 раз.
        Теперь с лучшим решением повторяем часть с координатами, только перебираем их парами, в порядке увеличения разности между ними (1..63, 0..63), и все возможные пары значений (-128..127, -128..127), если улучшилось - улучшаем.
        Забываем все, начинаем с начала, так как три компоненты не перебрать.

        На самом деле отжиг должен работать лучше, но вообще мне сейчас не понятно почему такой поиск работает значительно лучше случайного, но при этом не достаточно хорошо. Сейчас смотрю на возможность улучшения путем замены на мягкий максимум, или что-то подобное, кажется не работает.

        Забавно что стандартное ml не находит никаких зависимостей между исходным вектором и ошибкой дискретизации, в то время как уже с чистой ошибкой квантования появляется слабая корреляция.
        Причем с максимальной величиной ошибки R2=0.8%, а для средней абсолютной уже добрые R2=13%.


        1. thevlad
          12.12.2022 23:52

          Чем то напоминает классический 2-opt(можно и 3-opt) в https://en.wikipedia.org/wiki/Local_search_(optimization). Как мне кажется можно попытаться улучшить, если для двух пар значений считать оптимум не перебором, а как-то аналитически или более хитрым алгоритмом.


          1. gchebanov
            13.12.2022 01:08

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


            1. thevlad
              13.12.2022 02:28

              У меня появилась идея, формально A это оператор поворота с перекошенной метрикой, то есть он может уникально перевести исходный X в любую точку и назад. Соответственно, можно попробовать идти от обратного. Для заданного A*X найти максимально возможную ошибку, в данном случаи это будет С = max(q(X) - X). Теперь надо найти такое обратное преобразование, которое переведет данную точку С в ближайшую к X. Вроде бы шило на мыло, но мы избавились от оператора квантования, а так же можно использовать свойство, что A^-1 = A^T. Конечно если мы будем искать A в реальных числах это не даст глобальный оптимум, но возможно еще что-то можно придумать чтобы продвинуться.

              PS: мы можем взять для C лишь одну координату с максимальной ошибкой и соответственно надо подобрать лишь одну строку матрицы действующую на эту координату


              1. thevlad
                13.12.2022 03:39

                Ну и формально как я понял, даже в рамках исходной задачи:

                max abs (q(A*X) - A*X). так, как мы ищем максимум по каждой координате независимо, то и влиять на одну координату будет лишь одна строка матрицы. Соответственно, достаточно перебрать все строки матрицы и для каждой из них найти максимум по соответствующей координате. Максимум от максимумов для каждой из строк и будет ответом.


                1. thevlad
                  13.12.2022 04:15

                  Да и очевидно, что нам достаточно найти единственный вектор-строку дающий максимальную ошибку(V*X), остальную матрицу восстановить исходя из необходимых условий ортогональности.

                  PS: вроде нигде не напутал, если понял задачу правильно


        1. Melirius
          12.12.2022 23:52

          А MCMC плохо работает?


          1. gchebanov
            13.12.2022 01:11

            Не знаю такого, если речь о случайной выборке - то плохо (скорее всего даже 0.26 никогда не дождёшься).


      1. imageman
        13.12.2022 11:50

        И вот тут читаем местную статейку https://habr.com/ru/post/704432/?amp%3Butm_source=habrahabr&amp%3Butm_medium=rss Решаем как подбор гиперпараметров, ищем максимум (некоторое извращение, конечно, но должно сработать).


  1. xcilessMore
    13.12.2022 01:05
    +1

    PNG настолько неудобен — сложен и громоздок (по размеру файлов), что понадобились альтернативы в виде WebP, AVIF, JPEG XL, которые сжимают усерднее и тщательнее?

    Лично я давно стараюсь хранить картинки именно в PNG, если есть возможность (не считая собственноручно сделанных фоток). Про JPEG забыл, использую только в камерах смартфонов.


    1. nixtonixto
      13.12.2022 09:32
      +3

      Да, для картинок и фотографий он монструозен и увеличивает нагрузку на сеть, накопитель и процессор, к тому же он не поддерживает прогрессивный режим, когда картинку можно рассмотреть ещё до того, как она докачается. Особенно если они большого разрешения, под 4К-мониторы. В PNG выгодно хранить только графику — там ещё и выигрыш по размеру файла по сравнению с JPEG, и нет искажений на однотонных участках.


      1. xcilessMore
        13.12.2022 17:46

        Тут в комментах жалуются, что JPEG XL всё равно создаёт артефакты, ну да ладно, может, какая альтернатива привычному джипегу победит. AVIF, WebP 2.0, JPEG XL...


        1. masai
          14.12.2022 13:25

          В режиме lossless (который аналог PNG) не создаёт, а для сжатия с потерями нет кодека, который сжимал бы без артефактов.

          Если вы про комментарий, автор которого пишет: «Jpeg XL — тот же Jpeg только в профиль», то он, скажем так, слишком поверхностно изучил вопрос.

          Насчёт того, кто победит. Тут больше всё зависит от поддержки большими компаниями и поддержки софтом. Если фотоаппараты на телефонах сохраняют всё в старый JPEG, то у всех будет JPEG.


          1. xcilessMore
            14.12.2022 22:03
            +1

            В режиме lossless (который аналог PNG) не создаёт

            О, спасибо! Обнадёживает. Это здорово, на самом деле.


      1. Didimus
        13.12.2022 18:18
        +1

        Помню еще был тифф со сжатием от сканера для распознавания текста. Так он графический файл со сканом текста делал меньше самого распознанного теста, на лист А4 можно было получить всего пару кБ


        1. masai
          14.12.2022 13:30

          Как тут не вспомнить злую шутку, которую сыграл алгоритм JBIG2 с копирами Xerox.


  1. Petrvictorovich
    13.12.2022 02:58
    -19

    Осспаде! Даже всего лишь от прокручивания вниз, до конца - аж устал!

    Скажите, если мне от такой статьи безмерно о скучно, значит программизм - не моё?


  1. Zara6502
    13.12.2022 10:47

    когда публикую картинке в вэб всегда загоняю в Paint.Net и нажимаю Сохранить, обычно там стоит качество 95, меняю на 85. Файлы с 5-8 мб ужимаются до 0.8-1.5 Мб. На качестве визуально никак не отражается, часто еще делаю кроп и даунскейл. Чему я должен удивиться в таком процессе?


    1. Didimus
      13.12.2022 18:19
      +1

      Цифровая деградация


      1. Zara6502
        14.12.2022 04:36

        в чём? если я из картинки 3200х2000 делаю 320х200 то даже если исходник в PNG и результат в PNG - это в любом случае будет изображение обработанное фильтром. Если я развернутый JPG уменьшаю в 10 раз по каждой стороне, то есть фактически убиваю 99 пикселей из 100, а потом точно так же в памяти я получаю НОВУЮ картинку, которую ПЕРВЫЙ раз сжимаю в JPG.


        1. Didimus
          14.12.2022 05:49
          +1

          Создана еще одна копия. Есть вероятность, что она и останется вместо оригинала


  1. alliumnsk
    13.12.2022 11:44
    +1

    Кстати, у PNG размер может сильно изменяться от того, чем сжато, столкнулся с этим, когда думал хранить в PNG двоичные данные. Новый браузер сжимает хуже, чем старый FastStone Viewer.


  1. mkarev
    15.12.2022 07:10
    +1

    Формату JPEG в этом году исполнилось 30 лет, нужно отдать должное его разработчикам, т.к. он до сих пор имеет свою нишу.

    Впрочем, интерес представляет даже не сам JPEG, а возможность использования линейных преобразований для сжатия без потерь.

    В сжатии изображений преобразование предназначено для минимизации среднеквадратичной ошибки, вносимой квантованием. Оптимальным считается преобразование Карунена-Лоэва, а DCT-II является его наилучшим приближением для сжатия изображений. То есть, пеобразование является необходимым этапом при сжатии с Потерями.

    Если интерес представляет именно сжатие без потерь, стоит обратить внимание на соответсвующие инструменты кодирования:

    • предсказание DCT коэффициентов по соседним (уже закодированным) блокам (H.263/MPEG-4 Part 2)

    • предсказание пикселей текущего блока, по восстановленным соседним (Intra predict, H.264 и т.п.)

    • сегментация изображения с целью кодирования больших равномерных областей бОльшими блоками (квадро-дерево H.265 и т.п.)

    • для "Screen" контента использовать сжатие с палитрой (H.265)

    • продвинутые алгоритмы энтропийного сжатия остаточной информации Huffman (с фиксированными таблицами) -> CAVLC -> CABAC

    В современных форматах сжатия изображений это реализовано в том или ином виде.


  1. gchebanov
    15.12.2022 07:15
    +1

    Все, победил задачу, вернулся через пару дней и сходу нашел баг.

    вот вектор с ошибкой округления 0.4903, правда координаты к сожалению вылезли до 10^5.

    Забавно что решить помог тот самый алгоритм о котором смутно помнил, и спрашивал о нем чуть выше.

    На удивление высокое качество решения относительно возможного перебора: Положим результат округления случайным (-0.5,0.5). У моего решения они находятся в интервале [0.473, 0.5), таким образом шанс что все попадут в такой интервал (или лучший) порядка 10^{-100}. Биткоину до такого еще далеко :)

    Сейчас запустил перебор по координатам, наверняка что-то получше найдёт.

    P.S. Пока писал комментарий нашлось лучшее решение - i=16, err=0.4949.
    P.S. Отдельное спасибо комментариям в соседнем посте, после прочтения которых пришлось собраться с силами и вернуться к нерешенной проблеме.


    1. Fil Автор
      15.12.2022 08:51

      Здорово, спасибо! Теория подтверждается